From 16faa21527ff676a3507d6a91b608481eb2baae1 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 11 Jun 2024 17:44:02 +0300 Subject: [PATCH 01/23] feat: Confirm and Error Dialogs UI --- .../main/java/org/openedx/app/di/AppModule.kt | 4 +- .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../core/domain/model/AssignmentProgress.kt | 6 +- .../org/openedx/core/domain/model/Block.kt | 21 +- .../org/openedx/core/extension/LongExt.kt | 4 +- .../openedx/core/module/db/DownloadModel.kt | 6 +- .../module/download/BaseDownloadViewModel.kt | 2 +- core/src/main/res/values/strings.xml | 1 + .../domain/model/DownloadDialogResource.kt | 9 + .../download/DownloadConfirmDialogFragment.kt | 256 ++++++++++++++++++ .../download/DownloadConfirmDialogType.kt | 9 + .../download/DownloadDialogItem.kt | 10 + .../download/DownloadDialogManager.kt | 147 ++++++++++ .../download/DownloadDialogUIState.kt | 13 + .../download/DownloadErrorDialogFragment.kt | 197 ++++++++++++++ .../download/DownloadErrorDialogType.kt | 9 + .../presentation/download/DownloadView.kt | 48 ++++ .../outline/CourseOutlineViewModel.kt | 72 ++++- .../course/presentation/ui/CourseUI.kt | 3 +- .../src/main/res/drawable/course_ic_error.xml | 9 + course/src/main/res/values/strings.xml | 12 + 21 files changed, 817 insertions(+), 24 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt create mode 100644 course/src/main/res/drawable/course_ic_error.xml 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 1d1dc7e0c..0d6fc2425 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,10 +12,10 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME import org.openedx.app.system.notifier.AppNotifier @@ -53,6 +53,7 @@ import org.openedx.core.utils.FileUtil import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -83,6 +84,7 @@ val appModule = module { single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get(), get()) } + single { DownloadDialogManager(get(), get(), get()) } single { ImageProcessor(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 0c2c85474..406ba49b1 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -233,7 +233,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String) -> diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt index 659665bfe..730bfbfba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -1,7 +1,11 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class AssignmentProgress( val assignmentType: String, val numPointsEarned: Float, val numPointsPossible: Float -) +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 460f283ba..5b239668b 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -1,6 +1,9 @@ package org.openedx.core.domain.model +import android.os.Parcelable import android.webkit.URLUtil +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.module.db.DownloadModel @@ -9,7 +12,7 @@ import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil import java.util.Date - +@Parcelize data class Block( val id: String, val blockId: String, @@ -29,7 +32,7 @@ data class Block( val downloadModel: DownloadModel? = null, val assignmentProgress: AssignmentProgress?, val due: Date? -) { +) : Parcelable { val isDownloadable: Boolean get() { return studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true @@ -89,14 +92,16 @@ data class Block( val isSurveyBlock get() = type == BlockType.SURVEY } +@Parcelize data class StudentViewData( val onlyOnWeb: Boolean, - val duration: Any, + val duration: @RawValue Any, val transcripts: HashMap?, val encodedVideos: EncodedVideos?, val topicId: String, -) +) : Parcelable +@Parcelize data class EncodedVideos( val youtube: VideoInfo?, var hls: VideoInfo?, @@ -104,7 +109,7 @@ data class EncodedVideos( var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, var mobileLow: VideoInfo?, -) { +) : Parcelable { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || isPreferredVideoInfo(fallback) || @@ -184,11 +189,13 @@ data class EncodedVideos( } +@Parcelize data class VideoInfo( val url: String, val fileSize: Int, -) +) : Parcelable +@Parcelize data class BlockCounts( val video: Int, -) +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index 06f052616..ed84ef0b3 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -3,14 +3,14 @@ package org.openedx.core.extension import kotlin.math.log10 import kotlin.math.pow -fun Long.toFileSize(round: Int = 2): String { +fun Long.toFileSize(round: Int = 2, space: Boolean = true): 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] + ) + if (space) " " else "" + units[digitGroups] } catch (e: Exception) { println(e.toString()) } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index 86bc31540..c55a93d6a 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -1,5 +1,9 @@ package org.openedx.core.module.db +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class DownloadModel( val id: String, val title: String, @@ -9,7 +13,7 @@ data class DownloadModel( val type: FileType, val downloadedState: DownloadedState, val progress: Float? -) +) : Parcelable enum class DownloadedState { WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; 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 40cc94e4d..19f590b3a 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 @@ -28,7 +28,7 @@ abstract class BaseDownloadViewModel( private val analytics: CoreAnalytics, ) : BaseViewModel() { - private val allBlocks = hashMapOf() + val allBlocks = hashMapOf() private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 580d262ac..db4ee003a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -190,4 +190,5 @@ Discussions More Dates + Confirm Download diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt new file mode 100644 index 000000000..cded4944a --- /dev/null +++ b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt @@ -0,0 +1,9 @@ +package org.openedx.course.domain.model + +import androidx.compose.ui.graphics.painter.Painter + +data class DownloadDialogResource( + val title: String, + val description: String, + val icon: Painter? = null, +) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt new file mode 100644 index 000000000..0ed380947 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -0,0 +1,256 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toFileSize +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DownloadConfirmDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val sizeSumString = uiState.sizeSum.toFileSize(0, false) + val dialogData = when (dialogType) { + DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( + title = stringResource(id = coreR.string.course_confirm_download), + description = stringResource( + id = R.string.course_download_confirm_dialog_description, + sizeSumString + ), + ) + + DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_on_cellural), + description = stringResource(id = R.string.course_download_on_cellural_dialog_description), + icon = painterResource(id = org.openedx.core.R.drawable.core_ic_warning), + ) + + DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_remove_offline_content), + description = stringResource( + id = R.string.course_download_remove_dialog_description, + sizeSumString + ) + ) + } + + DownloadConfirmDialogView( + downloadDialogResource = dialogData, + uiState = uiState, + dialogType = dialogType, + onConfirmClick = { + uiState.saveDownloadModels() + dismiss() + }, + onRemoveClick = { + uiState.removeDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadConfirmDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadConfirmDialogType, + uiState: DownloadDialogUIState + ): DownloadConfirmDialogFragment { + val dialog = DownloadConfirmDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadConfirmDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + dialogType: DownloadConfirmDialogType, + onRemoveClick: () -> Unit, + onConfirmClick: () -> Unit, + onCancelClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + + val buttonText: String + val buttonIcon: Painter + val buttonColor: ComposeColor + val onClick: () -> Unit + when (dialogType) { + DownloadConfirmDialogType.REMOVE -> { + buttonText = stringResource(id = R.string.course_remove) + buttonIcon = rememberVectorPainter(Icons.Rounded.Delete) + buttonColor = MaterialTheme.appColors.error + onClick = onRemoveClick + } + + else -> { + buttonText = stringResource(id = R.string.course_download) + buttonIcon = painterResource(id = R.drawable.course_ic_start_download) + buttonColor = MaterialTheme.appColors.secondaryButtonBackground + onClick = onConfirmClick + } + } + OpenEdXButton( + text = buttonText, + backgroundColor = buttonColor, + onClick = onClick, + content = { + IconText( + text = buttonText, + painter = buttonIcon, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.core.R.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadConfirmDialogViewPreview() { + OpenEdXTheme { + DownloadConfirmDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description " + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 1000000, + isAllBlocksDownloaded = false, + saveDownloadModels = {}, + removeDownloadModels = {} + ), + dialogType = DownloadConfirmDialogType.CONFIRM, + onConfirmClick = {}, + onRemoveClick = {}, + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt new file mode 100644 index 000000000..4418c322c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadConfirmDialogType : Parcelable { + DOWNLOAD_ON_CELLULAR, CONFIRM, REMOVE +} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt new file mode 100644 index 000000000..1f020e698 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt @@ -0,0 +1,10 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class DownloadDialogItem( + val title: String, + val size: Long +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt new file mode 100644 index 000000000..33bf1b90e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -0,0 +1,147 @@ +package org.openedx.course.presentation.download + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class DownloadDialogManager( + private val networkConnection: NetworkConnection, + private val corePreferences: CorePreferences, + private val interactor: CourseInteractor, +) { + + companion object { + const val MAX_CELLURAL_SIZE = 100000000 // 100MB + } + + private val uiState = MutableSharedFlow() + private var fragmentManager: FragmentManager? = null + + init { + CoroutineScope(Dispatchers.IO).launch { + uiState.collect { uiState -> + fragmentManager?.let { fm -> + when { + uiState.isAllBlocksDownloaded -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.REMOVE, + uiState = uiState + ) + dialog.show( + fm, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + !networkConnection.isOnline() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.NO_CONNECTION, + uiState = uiState + ) + dialog.show( + fm, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.WIFI_REQUIRED, + uiState = uiState + ) + dialog.show( + fm, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() && uiState.sizeSum >= MAX_CELLURAL_SIZE -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, + uiState = uiState + ) + dialog.show( + fm, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + else -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, + uiState = uiState + ) + dialog.show( + fm, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + } + } + } + } + } + + fun showPopup( + subSectionsBlocks: List, + courseId: String, + isAllBlocksDownloaded: Boolean, + fm: FragmentManager, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + fragmentManager = fm + getDownloadItems( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + isAllBlocksDownloaded = isAllBlocksDownloaded, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels + ) + } + + private fun getDownloadItems( + subSectionsBlocks: List, + courseId: String, + isAllBlocksDownloaded: Boolean, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + CoroutineScope(Dispatchers.IO).launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } } + val size = blocks.mapNotNull { it.downloadModel?.size }.sum().toLong() + if (size > 0) { + DownloadDialogItem(title = subSectionsBlock.displayName, size = size) + } else { + null + } + } + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isAllBlocksDownloaded, + sizeSum = downloadDialogItems.sumOf { it.size }, + removeDownloadModels = { + subSectionsBlocks.forEach { + removeDownloadModels(it.id) + } + }, + saveDownloadModels = { + subSectionsBlocks.forEach { + saveDownloadModels(it.id) + } + } + ) + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt new file mode 100644 index 000000000..11164e521 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class DownloadDialogUIState( + val downloadDialogItems: List = emptyList(), + val sizeSum: Long, + val isAllBlocksDownloaded: Boolean, + val removeDownloadModels: () -> Unit, + val saveDownloadModels: () -> Unit +) : Parcelable \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt new file mode 100644 index 000000000..107822385 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -0,0 +1,197 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toFileSize +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.core.R as coreR + +class DownloadErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = when (dialogType) { + DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( + title = stringResource(id = coreR.string.core_no_internet_connection), + description = stringResource(id = R.string.course_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( + title = stringResource(id = R.string.course_wifi_required), + description = stringResource(id = R.string.course_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_failed), + description = stringResource(id = R.string.course_download_failed_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + } + + DownloadErrorDialogView( + downloadDialogResource = downloadDialogResource, + uiState = uiState, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadErrorDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadErrorDialogType, + uiState: DownloadDialogUIState + ): DownloadErrorDialogFragment { + val dialog = DownloadErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + onCancelClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.core.R.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadErrorDialogViewPreview() { + OpenEdXTheme { + DownloadErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt new file mode 100644 index 000000000..12341c9a7 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadErrorDialogType : Parcelable { + NO_CONNECTION, WIFI_REQUIRED, DOWNLOAD_FAILED +} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt new file mode 100644 index 000000000..e22a0a841 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -0,0 +1,48 @@ +package org.openedx.course.presentation.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +fun DownloadDialogItem( + modifier: Modifier = Modifier, + title: String, + size: String +) { + Row( + modifier = modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_core_chapter_icon), + tint = MaterialTheme.appColors.textDark, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + modifier = Modifier.weight(1f), + text = title, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + Text( + text = size, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textFieldHint + ) + } +} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 6ea080957..56d161568 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -43,6 +43,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.course.R as courseR class CourseOutlineViewModel( @@ -55,6 +56,7 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, @@ -388,20 +390,74 @@ class CourseOutlineViewModel( } } + fun findBlocksAtBlockLevel(blockData: List, rootBlockId: String): List { + val rootBlock = blockData.find { it.id == rootBlockId } ?: return emptyList() + val blockLevelBlocks = mutableListOf() + + fun dfs(block: Block) { + block.descendants.forEach { descendantId -> + val descendantBlock = blockData.find { it.id == descendantId } + if (descendantBlock != null) { + if (descendantBlock.descendants.isEmpty()) { + // Якщо поточний descendant блок не має нащадків, + // то додаємо його до списку blockLevelBlocks + blockLevelBlocks.add(descendantBlock) + } else { + // Інакше викликаємо dfs для рекурсивного перегляду нащадків descendant блоку + dfs(descendantBlock) + } + } + } + } + + dfs(rootBlock) + return blockLevelBlocks + } + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { - blocksIds.forEach { blockId -> - if (isBlockDownloading(blockId)) { + viewModelScope.launch { + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val downloadedBlocks = blocksIds.filter { isBlockDownloaded(it) } + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.mapNotNull { getDownloadableChildren(it) }.flatten() courseRouter.navigateToDownloadQueue( fm = fragmentManager, - getDownloadableChildren(blockId) ?: arrayListOf() + downloadableChildren ) - } else if (isBlockDownloaded(blockId)) { - removeDownloadModels(blockId) } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId - ) + (_uiState.value as? CourseOutlineUIState.CourseData)?.let { courseData -> + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + downloadDialogManager.showPopup( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + isAllBlocksDownloaded = downloadedBlocks.isNotEmpty(), + fm = fragmentManager, + removeDownloadModels = { blockId -> + removeDownloadModels(blockId) + }, + saveDownloadModels = { blockId -> + saveDownloadModels( + FileUtil(context).getExternalAppDir().path, blockId + ) + } + ) + } } } + +// blocksIds.forEach { blockId -> +// if (isBlockDownloading(blockId)) { +// courseRouter.navigateToDownloadQueue( +// fm = fragmentManager, +// getDownloadableChildren(blockId) ?: arrayListOf() +// ) +// } else if (isBlockDownloaded(blockId)) { +// removeDownloadModels(blockId) +// } else { +// saveDownloadModels( +// FileUtil(context).getExternalAppDir().path, blockId +// ) +// } +// } } } 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 7d8729fac..73a844e0e 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 @@ -603,7 +603,6 @@ fun CourseSection( filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED } - val downloadBlockIds = downloadedStateMap.keys.filter { it in block.descendants } Column(modifier = modifier .clip(MaterialTheme.appShapes.cardShape) @@ -620,7 +619,7 @@ fun CourseSection( arrowDegrees = arrowRotation, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(downloadBlockIds) + onDownloadClick(block.descendants) } ) courseSubSections?.forEach { subSectionBlock -> diff --git a/course/src/main/res/drawable/course_ic_error.xml b/course/src/main/res/drawable/course_ic_error.xml new file mode 100644 index 000000000..4454ecf7c --- /dev/null +++ b/course/src/main/res/drawable/course_ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2fcb1d950..91627a1b3 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -63,6 +63,18 @@ Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? %1$s - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use 99MB of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove %1$s of %2$s assignments complete From 25a7123b3b55f6c3ae544d3bee0604f4d40943b7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 12 Jun 2024 12:25:26 +0300 Subject: [PATCH 02/23] feat: Confirm and Error Dialogs UI --- .../core/system/PreviewFragmentManager.kt | 5 +++++ .../download/DownloadConfirmDialogFragment.kt | 4 +++- .../download/DownloadDialogManager.kt | 19 +++++++++---------- .../download/DownloadDialogUIState.kt | 3 +++ .../download/DownloadErrorDialogFragment.kt | 2 ++ .../outline/CourseOutlineViewModel.kt | 2 +- 6 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt diff --git a/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt b/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt new file mode 100644 index 000000000..36d4b39eb --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system + +import androidx.fragment.app.FragmentManager + +object PreviewFragmentManager : FragmentManager() diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 0ed380947..6eb44ff1d 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -36,6 +36,7 @@ import androidx.fragment.app.DialogFragment import org.openedx.core.extension.parcelable import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -245,7 +246,8 @@ private fun DownloadConfirmDialogViewPreview() { sizeSum = 1000000, isAllBlocksDownloaded = false, saveDownloadModels = {}, - removeDownloadModels = {} + removeDownloadModels = {}, + fragmentManager = PreviewFragmentManager ), dialogType = DownloadConfirmDialogType.CONFIRM, onConfirmClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index 33bf1b90e..a5d6a7745 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -21,12 +21,10 @@ class DownloadDialogManager( } private val uiState = MutableSharedFlow() - private var fragmentManager: FragmentManager? = null init { CoroutineScope(Dispatchers.IO).launch { uiState.collect { uiState -> - fragmentManager?.let { fm -> when { uiState.isAllBlocksDownloaded -> { val dialog = DownloadConfirmDialogFragment.newInstance( @@ -34,7 +32,7 @@ class DownloadDialogManager( uiState = uiState ) dialog.show( - fm, + uiState.fragmentManager, DownloadConfirmDialogFragment.DIALOG_TAG ) } @@ -45,7 +43,7 @@ class DownloadDialogManager( uiState = uiState ) dialog.show( - fm, + uiState.fragmentManager, DownloadErrorDialogFragment.DIALOG_TAG ) } @@ -56,7 +54,7 @@ class DownloadDialogManager( uiState = uiState ) dialog.show( - fm, + uiState.fragmentManager, DownloadErrorDialogFragment.DIALOG_TAG ) } @@ -67,7 +65,7 @@ class DownloadDialogManager( uiState = uiState ) dialog.show( - fm, + uiState.fragmentManager, DownloadConfirmDialogFragment.DIALOG_TAG ) } @@ -78,11 +76,10 @@ class DownloadDialogManager( uiState = uiState ) dialog.show( - fm, + uiState.fragmentManager, DownloadConfirmDialogFragment.DIALOG_TAG ) } - } } } } @@ -92,14 +89,14 @@ class DownloadDialogManager( subSectionsBlocks: List, courseId: String, isAllBlocksDownloaded: Boolean, - fm: FragmentManager, + fragmentManager: FragmentManager, removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { - fragmentManager = fm getDownloadItems( subSectionsBlocks = subSectionsBlocks, courseId = courseId, + fragmentManager = fragmentManager, isAllBlocksDownloaded = isAllBlocksDownloaded, removeDownloadModels = removeDownloadModels, saveDownloadModels = saveDownloadModels @@ -109,6 +106,7 @@ class DownloadDialogManager( private fun getDownloadItems( subSectionsBlocks: List, courseId: String, + fragmentManager: FragmentManager, isAllBlocksDownloaded: Boolean, removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, @@ -130,6 +128,7 @@ class DownloadDialogManager( downloadDialogItems = downloadDialogItems, isAllBlocksDownloaded = isAllBlocksDownloaded, sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt index 11164e521..59bfba0b4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -1,13 +1,16 @@ package org.openedx.course.presentation.download import android.os.Parcelable +import androidx.fragment.app.FragmentManager import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue @Parcelize data class DownloadDialogUIState( val downloadDialogItems: List = emptyList(), val sizeSum: Long, val isAllBlocksDownloaded: Boolean, + var fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, val saveDownloadModels: () -> Unit ) : Parcelable \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 107822385..74102e443 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -32,6 +32,7 @@ import androidx.fragment.app.DialogFragment import org.openedx.core.extension.parcelable import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -188,6 +189,7 @@ private fun DownloadErrorDialogViewPreview() { ), sizeSum = 100000, isAllBlocksDownloaded = false, + fragmentManager = PreviewFragmentManager, removeDownloadModels = {}, saveDownloadModels = {} ), diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 56d161568..f1ed2521d 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -431,7 +431,7 @@ class CourseOutlineViewModel( subSectionsBlocks = subSectionsBlocks, courseId = courseId, isAllBlocksDownloaded = downloadedBlocks.isNotEmpty(), - fm = fragmentManager, + fragmentManager = fragmentManager, removeDownloadModels = { blockId -> removeDownloadModels(blockId) }, From ef007ee2037818fb689cda9695e6e58bd6ad82ea Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 12 Jun 2024 20:26:06 +0300 Subject: [PATCH 03/23] feat: DownloadStorageErrorDialogFragment --- .../org/openedx/core/system/StorageManager.kt | 21 ++ .../download/DownloadDialogManager.kt | 15 +- .../DownloadStorageErrorDialogFragment.kt | 254 ++++++++++++++++++ course/src/main/res/values/strings.xml | 3 + 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/system/StorageManager.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt diff --git a/core/src/main/java/org/openedx/core/system/StorageManager.kt b/core/src/main/java/org/openedx/core/system/StorageManager.kt new file mode 100644 index 000000000..895072fb1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/StorageManager.kt @@ -0,0 +1,21 @@ +package org.openedx.core.system + +import android.os.Environment +import android.os.StatFs + +object StorageManager { + + fun getTotalStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val totalBlocks = stat.blockCountLong + return totalBlocks * blockSize + } + + fun getFreeStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + return availableBlocks * blockSize + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index a5d6a7745..71d9710d0 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -7,13 +7,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block +import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.domain.interactor.CourseInteractor class DownloadDialogManager( private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val interactor: CourseInteractor, + private val interactor: CourseInteractor ) { companion object { @@ -48,6 +49,16 @@ class DownloadDialogManager( ) } + StorageManager.getFreeStorage() > uiState.sizeSum -> { + val dialog = DownloadStorageErrorDialogFragment.newInstance( + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadStorageErrorDialogFragment.DIALOG_TAG + ) + } + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { val dialog = DownloadErrorDialogFragment.newInstance( dialogType = DownloadErrorDialogType.WIFI_REQUIRED, @@ -127,7 +138,7 @@ class DownloadDialogManager( DownloadDialogUIState( downloadDialogItems = downloadDialogItems, isAllBlocksDownloaded = isAllBlocksDownloaded, - sizeSum = downloadDialogItems.sumOf { it.size }, + sizeSum = downloadDialogItems.sumOf { it.size } * 2, fragmentManager = fragmentManager, removeDownloadModels = { subSectionsBlocks.forEach { diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt new file mode 100644 index 000000000..c966e19ab --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -0,0 +1,254 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toFileSize +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.system.StorageManager +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.domain.model.DownloadDialogResource + +class DownloadStorageErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = DownloadDialogResource( + title = stringResource(id = org.openedx.course.R.string.course_device_storage_full), + description = stringResource(id = org.openedx.course.R.string.course_download_device_storage_full_dialog_description), + icon = painterResource(id = org.openedx.course.R.drawable.course_ic_error), + ) + + DownloadStorageErrorDialogView( + uiState = uiState, + downloadDialogResource = downloadDialogResource, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + uiState: DownloadDialogUIState + ): DownloadStorageErrorDialogFragment { + val dialog = DownloadStorageErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadStorageErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + onCancelClick: () -> Unit, +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + } + } + StorageBar( + usedSpace = StorageManager.getTotalStorage() - StorageManager.getFreeStorage(), + freeSpace = StorageManager.getFreeStorage(), + totalSpace = StorageManager.getTotalStorage(), + requiredSpace = (StorageManager.getFreeStorage() * 1.3f).toLong() + ) + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Composable +private fun StorageBar( + usedSpace: Long, + freeSpace: Long, + totalSpace: Long, + requiredSpace: Long +) { + val usedPercentage = (totalSpace + requiredSpace - freeSpace) / totalSpace.toFloat() + val requiredPercentage = (requiredSpace - freeSpace) / totalSpace.toFloat() + + Box( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(androidx.compose.ui.graphics.Color.Gray) + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Canvas( + modifier = Modifier + .weight(usedPercentage) + .fillMaxHeight() + ) { + drawRoundRect(color = androidx.compose.ui.graphics.Color.Gray) + } + Canvas( + modifier = Modifier + .weight(requiredPercentage) + .fillMaxHeight() + ) { + drawRoundRect(color = androidx.compose.ui.graphics.Color.Red) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text( + text = stringResource( + org.openedx.course.R.string.course_used_free_storage, + usedSpace.toFileSize(0, false), + freeSpace.toFileSize(0, false) + ), + style = MaterialTheme.typography.body1.copy(fontSize = 14.sp), + modifier = Modifier.weight(1f) + ) + Text( + text = requiredSpace.toFileSize(0, false), + style = MaterialTheme.typography.body1.copy( + color = androidx.compose.ui.graphics.Color.Red, + fontSize = 14.sp + ) + ) + } +} + +@Preview +@Composable +private fun DownloadStorageErrorDialogViewPreview() { + OpenEdXTheme { + DownloadStorageErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = org.openedx.course.R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {} + ) + } +} \ No newline at end of file diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 91627a1b3..a0cda4fa4 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -75,6 +75,9 @@ Removing this content will free up %1$s. Download Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free %1$s of %2$s assignments complete From f3d3e4c2d14f279b6c33144bf6b8b012aedd56ad Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 12 Jun 2024 22:35:02 +0300 Subject: [PATCH 04/23] feat: StorageBar --- .../download/DownloadDialogManager.kt | 2 +- .../DownloadStorageErrorDialogFragment.kt | 113 +++++++++++------- 2 files changed, 69 insertions(+), 46 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index 71d9710d0..0296d2595 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -49,7 +49,7 @@ class DownloadDialogManager( ) } - StorageManager.getFreeStorage() > uiState.sizeSum -> { + StorageManager.getFreeStorage() < uiState.sizeSum -> { val dialog = DownloadStorageErrorDialogFragment.newInstance( uiState = uiState ) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index c966e19ab..f243e192a 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -5,35 +5,40 @@ import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.Canvas +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -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.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.R @@ -136,10 +141,9 @@ private fun DownloadStorageErrorDialogView( } } StorageBar( - usedSpace = StorageManager.getTotalStorage() - StorageManager.getFreeStorage(), freeSpace = StorageManager.getFreeStorage(), totalSpace = StorageManager.getTotalStorage(), - requiredSpace = (StorageManager.getFreeStorage() * 1.3f).toLong() + requiredSpace = uiState.sizeSum ) Text( text = downloadDialogResource.description, @@ -162,62 +166,81 @@ private fun DownloadStorageErrorDialogView( @Composable private fun StorageBar( - usedSpace: Long, freeSpace: Long, totalSpace: Long, requiredSpace: Long ) { + val cornerRadius = 2.dp + val boxPadding = 1.dp + val usedSpace = totalSpace - freeSpace val usedPercentage = (totalSpace + requiredSpace - freeSpace) / totalSpace.toFloat() - val requiredPercentage = (requiredSpace - freeSpace) / totalSpace.toFloat() + val reqPercentage = (requiredSpace - freeSpace) / totalSpace.toFloat() - Box( - modifier = Modifier - .fillMaxWidth() - .height(24.dp) - .background(androidx.compose.ui.graphics.Color.Gray) + val animReqPercentage = remember { Animatable(Float.MIN_VALUE) } + LaunchedEffect(Unit) { + animReqPercentage.animateTo( + targetValue = reqPercentage, + animationSpec = tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(2.dp) + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(MaterialTheme.appColors.background) + .clip(RoundedCornerShape(cornerRadius)) + .border( + 2.dp, + MaterialTheme.appColors.cardViewBorder, + RoundedCornerShape(cornerRadius * 2) + ) + .padding(2.dp) + .background(MaterialTheme.appColors.background), ) { - Canvas( + Box( modifier = Modifier .weight(usedPercentage) .fillMaxHeight() - ) { - drawRoundRect(color = androidx.compose.ui.graphics.Color.Gray) - } - Canvas( + .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) + .background(MaterialTheme.appColors.cardViewBorder) + ) + Box( modifier = Modifier - .weight(requiredPercentage) + .weight(animReqPercentage.value) .fillMaxHeight() - ) { - drawRoundRect(color = androidx.compose.ui.graphics.Color.Red) - } + .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) + .background(MaterialTheme.appColors.error) + ) } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - Text( - text = stringResource( - org.openedx.course.R.string.course_used_free_storage, - usedSpace.toFileSize(0, false), - freeSpace.toFileSize(0, false) - ), - style = MaterialTheme.typography.body1.copy(fontSize = 14.sp), - modifier = Modifier.weight(1f) - ) - Text( - text = requiredSpace.toFileSize(0, false), - style = MaterialTheme.typography.body1.copy( - color = androidx.compose.ui.graphics.Color.Red, - fontSize = 14.sp + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource( + org.openedx.course.R.string.course_used_free_storage, + usedSpace.toFileSize(0, false), + freeSpace.toFileSize(0, false) + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint, + modifier = Modifier.weight(1f) ) - ) + Text( + text = requiredSpace.toFileSize(0, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.error, + ) + } } } From 7e7f432f7b0336a474130f7175b41e5eeeb8d5c0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Jun 2024 18:03:05 +0300 Subject: [PATCH 05/23] feat: Download HTML block --- .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 7 +- build.gradle | 1 + core/build.gradle | 3 + .../java/org/openedx/core/data/model/Block.kt | 9 +- .../core/data/model/OfflineDownload.kt | 26 ++++ .../openedx/core/data/model/room/BlockDb.kt | 27 +++- .../org/openedx/core/domain/model/Block.kt | 23 ++- .../org/openedx/core/module/DownloadWorker.kt | 11 +- .../openedx/core/module/db/DownloadModel.kt | 7 +- .../core/module/db/DownloadModelEntity.kt | 14 +- .../module/download/BaseDownloadViewModel.kt | 30 +--- .../core/module/download/DownloadHelper.kt | 93 ++++++++++++ .../java/org/openedx/core/utils/FileUtil.kt | 45 ++++++ .../data/repository/CourseRepository.kt | 2 +- .../download/DownloadDialogManager.kt | 137 ++++++++++-------- .../outline/CourseOutlineScreen.kt | 7 +- .../outline/CourseOutlineViewModel.kt | 101 +++++-------- .../section/CourseSectionFragment.kt | 5 +- .../section/CourseSectionViewModel.kt | 5 +- .../course/presentation/ui/CourseUI.kt | 5 +- .../course/presentation/ui/CourseVideosUI.kt | 6 +- .../container/CourseUnitContainerAdapter.kt | 16 +- .../container/CourseUnitContainerViewModel.kt | 5 + .../unit/html/HtmlUnitFragment.kt | 37 ++++- .../videos/CourseVideoViewModel.kt | 7 +- .../download/DownloadQueueFragment.kt | 4 +- .../download/DownloadQueueViewModel.kt | 11 +- 28 files changed, 448 insertions(+), 198 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt create mode 100644 core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt 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 0d6fc2425..b085e2b9c 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -33,6 +33,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics @@ -188,4 +189,5 @@ val appModule = module { factory { OAuthHelper(get(), get(), get()) } factory { FileUtil(get()) } + single { DownloadHelper(get(), 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 406ba49b1..7a01ea326 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -235,6 +235,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String) -> @@ -249,6 +250,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, unitId: String) -> @@ -259,6 +261,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -276,7 +279,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -400,6 +404,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } diff --git a/build.gradle b/build.gradle index 250f56863..c24595d6d 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,7 @@ ext { configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) + zip_version = '2.6.3' //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' diff --git a/core/build.gradle b/core/build.gradle index 2360efd4d..f6c613b39 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -161,6 +161,9 @@ dependencies { api "com.google.android.gms:play-services-ads-identifier:18.0.1" api "com.android.installreferrer:installreferrer:2.2" + // Zip + api "net.lingala.zip4j:zip4j:$zip_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index b5581209f..c4b50df63 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -41,7 +41,9 @@ data class Block( @SerializedName("assignment_progress") val assignmentProgress: AssignmentProgress?, @SerializedName("due") - val due: String? + val due: String?, + @SerializedName("offline_download") + val offlineDownload: OfflineDownload?, ) { fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") @@ -73,6 +75,7 @@ data class Block( containsGatedContent = containsGatedContent ?: false, assignmentProgress = assignmentProgress?.mapToDomain(), due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } } @@ -133,7 +136,7 @@ data class VideoInfo( @SerializedName("url") var url: String?, @SerializedName("file_size") - var fileSize: Int? + var fileSize: Long? ) { fun mapToDomain(): DomainVideoInfo { return DomainVideoInfo( @@ -152,4 +155,4 @@ data class BlockCounts( video = video ?: 0 ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt new file mode 100644 index 000000000..40868fc7a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.OfflineDownloadDb +import org.openedx.core.domain.model.OfflineDownload + +data class OfflineDownload( + @SerializedName("file_url") + var fileUrl: String?, + @SerializedName("last_modified") + var lastModified: String?, + @SerializedName("file_size") + var fileSize: Long?, +) { + fun mapToDomain() = OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + + fun mapToRoomEntity() = OfflineDownloadDb( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index 737437dd0..70ddfdf79 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -48,7 +48,9 @@ data class BlockDb( @Embedded val assignmentProgress: AssignmentProgressDb?, @ColumnInfo("due") - val due: String? + val due: String?, + @Embedded + val offlineDownload: OfflineDownloadDb?, ) { fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) @@ -80,6 +82,7 @@ data class BlockDb( containsGatedContent = containsGatedContent, assignmentProgress = assignmentProgress?.mapToDomain(), due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } @@ -105,7 +108,8 @@ data class BlockDb( completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, assignmentProgress = assignmentProgress?.mapToRoomEntity(), - due = due + due = due, + offlineDownload = offlineDownload?.mapToRoomEntity() ) } } @@ -193,7 +197,7 @@ data class VideoInfoDb( @ColumnInfo("url") val url: String, @ColumnInfo("fileSize") - val fileSize: Int + val fileSize: Long ) { fun mapToDomain() = DomainVideoInfo(url, fileSize) @@ -235,3 +239,20 @@ data class AssignmentProgressDb( numPointsPossible = numPointsPossible ?: 0f ) } + +data class OfflineDownloadDb( + @ColumnInfo("file_url") + var fileUrl: String?, + @ColumnInfo("last_modified") + var lastModified: String?, + @ColumnInfo("file_size") + var fileSize: Long?, +) { + fun mapToDomain(): org.openedx.core.domain.model.OfflineDownload { + return org.openedx.core.domain.model.OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 5b239668b..63fc306ce 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -31,22 +31,26 @@ data class Block( val containsGatedContent: Boolean = false, val downloadModel: DownloadModel? = null, val assignmentProgress: AssignmentProgress?, - val due: Date? + val due: Date?, + val offlineDownload: OfflineDownload? ) : Parcelable { val isDownloadable: Boolean get() { - return studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true + return (studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true) || + (!offlineDownload?.fileUrl.isNullOrEmpty()) } - val downloadableType: FileType + val downloadableType: FileType? get() = when (type) { BlockType.VIDEO -> { FileType.VIDEO } - else -> { - FileType.UNKNOWN + BlockType.HTML -> { + FileType.HTML } + + else -> null } fun isDownloading(): Boolean { @@ -192,10 +196,17 @@ data class EncodedVideos( @Parcelize data class VideoInfo( val url: String, - val fileSize: Int, + val fileSize: Long, ) : Parcelable @Parcelize data class BlockCounts( val video: Int, ) : Parcelable + +@Parcelize +data class OfflineDownload( + var fileUrl: String, + var lastModified: String?, + var fileSize: Long, +) : Parcelable 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 736a1b1ce..55a40c49a 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -20,11 +20,11 @@ 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.CurrentProgress +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged import org.openedx.core.utils.FileUtil -import java.io.File class DownloadWorker( val context: Context, @@ -39,6 +39,7 @@ class DownloadWorker( private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private var downloadEnqueue = listOf() @@ -133,13 +134,9 @@ class DownloadWorker( ) val isSuccess = fileDownloader.download(downloadTask.url, downloadTask.path) if (isSuccess) { + val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom( - downloadTask.copy( - downloadedState = DownloadedState.DOWNLOADED, - size = File(downloadTask.path).length().toInt() - ) - ) + DownloadModelEntity.createFrom(updatedModel) ) } else { downloadDao.removeDownloadModel(downloadTask.id) diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index c55a93d6a..016466a9e 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -7,12 +7,13 @@ import kotlinx.parcelize.Parcelize data class DownloadModel( val id: String, val title: String, - val size: Int, + val courseId: String, + val size: Long, val path: String, val url: String, val type: FileType, val downloadedState: DownloadedState, - val progress: Float? + val lastModified: String? = null, ) : Parcelable enum class DownloadedState { @@ -30,5 +31,5 @@ enum class DownloadedState { } enum class FileType { - VIDEO, UNKNOWN + VIDEO, HTML } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt index cd12a4eea..4e1a2f2cf 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt @@ -11,8 +11,10 @@ data class DownloadModelEntity( val id: String, @ColumnInfo("title") val title: String, + @ColumnInfo("courseId") + val courseId: String, @ColumnInfo("size") - val size: Int, + val size: Long, @ColumnInfo("path") val path: String, @ColumnInfo("url") @@ -21,19 +23,20 @@ data class DownloadModelEntity( val type: String, @ColumnInfo("downloadedState") val downloadedState: String, - @ColumnInfo("progress") - val progress: Float? + @ColumnInfo("lastModified") + val lastModified: String? ) { fun mapToDomain() = DownloadModel( id, title, + courseId, size, path, url, FileType.valueOf(type), DownloadedState.valueOf(downloadedState), - progress + lastModified ) companion object { @@ -43,12 +46,13 @@ data class DownloadModelEntity( return DownloadModelEntity( id, title, + courseId, size, path, url, type.name, downloadedState.name, - progress + lastModified ) } } 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 19f590b3a..f960bbecf 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 @@ -17,8 +17,6 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.utils.Sha1Util -import java.io.File abstract class BaseDownloadViewModel( private val courseId: String, @@ -26,6 +24,7 @@ abstract class BaseDownloadViewModel( private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, + private val downloadHelper: DownloadHelper, ) : BaseViewModel() { val allBlocks = hashMapOf() @@ -126,28 +125,11 @@ abstract class BaseDownloadViewModel( 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 - ) - ) + val downloadModel = downloadHelper.generateDownloadModelFromBlock(folder, block, courseId) + val isNotDownloaded = + downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null + if (isNotDownloaded && downloadModel != null) { + downloadModels.add(downloadModel) } } } diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt new file mode 100644 index 000000000..72c39fb43 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -0,0 +1,93 @@ +package org.openedx.core.module.download + +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +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.utils.FileUtil +import org.openedx.core.utils.Sha1Util +import java.io.File + +class DownloadHelper( + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, +) { + + fun generateDownloadModelFromBlock( + folder: String, + block: Block, + courseId: String + ): DownloadModel? { + return when (val downloadableType = block.downloadableType) { + FileType.VIDEO -> { + 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(url)}.$extension" + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + null + ) + } + + FileType.HTML -> { + val url = if (block.downloadableType == FileType.HTML) { + block.offlineDownload?.fileUrl ?: "" + } else { + "" + } + val size = block.offlineDownload?.fileSize ?: 0 + val extension = "zip" + val path = + folder + File.separator + "${Sha1Util.SHA1(url)}.$extension" + val lastModified = block.offlineDownload?.lastModified + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + lastModified + ) + } + + null -> null + } + } + + suspend fun updateDownloadStatus(downloadModel: DownloadModel): DownloadModel { + return when (downloadModel.type) { + FileType.VIDEO -> { + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = File(downloadModel.path).length() + ) + } + + FileType.HTML -> { + val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = File(unzippedFolderPath ?: "").length(), + path = unzippedFolderPath ?: "" + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/utils/FileUtil.kt b/core/src/main/java/org/openedx/core/utils/FileUtil.kt index a59317193..7c7423e60 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,8 +1,11 @@ package org.openedx.core.utils import android.content.Context +import android.util.Log import com.google.gson.Gson import com.google.gson.GsonBuilder +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.exception.ZipException import java.io.File import java.util.Collections @@ -72,6 +75,48 @@ class FileUtil(val context: Context) { // noinspection ResultOfMethodCallIgnored fileOrDirectory.delete() } + + fun unzipFile(filepath: String): String? { + val archive = File(filepath) + val destinationFolder = File( + archive.parentFile.absolutePath + "/" + archive.name + "-unzipped" + ) + try { + if (!destinationFolder.exists()) { + destinationFolder.mkdirs() + } + val zip = ZipFile(archive) + zip.extractAll(destinationFolder.absolutePath) + deleteFile(archive.absolutePath) + return destinationFolder.absolutePath + } catch (e: ZipException) { + e.printStackTrace() + deleteFile(destinationFolder.absolutePath) + } + return null + } + + private fun deleteFile(filepath: String?): Boolean { + try { + if (filepath != null) { + val file = File(filepath) + if (file.exists()) { + if (file.delete()) { + Log.d(this.javaClass.name, "Deleted: " + file.path) + return true + } else { + Log.d(this.javaClass.name, "Delete failed: " + file.path) + } + } else { + Log.d(this.javaClass.name, "Delete failed, file does NOT exist: " + file.path) + return true + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return false + } } enum class Directories { diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index c32397a48..e1be88b7a 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -39,7 +39,7 @@ class CourseRepository( if (networkConnection.isOnline()) { val response = api.getCourseStructure( "stale-if-error=0", - "v3", + "v4", preferencesManager.user?.username, courseId ) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index 0296d2595..da0b5727f 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.system.StorageManager @@ -18,7 +19,7 @@ class DownloadDialogManager( ) { companion object { - const val MAX_CELLURAL_SIZE = 100000000 // 100MB + const val MAX_CELLULAR_SIZE = 100000000 // 100MB } private val uiState = MutableSharedFlow() @@ -26,71 +27,71 @@ class DownloadDialogManager( init { CoroutineScope(Dispatchers.IO).launch { uiState.collect { uiState -> - when { - uiState.isAllBlocksDownloaded -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.REMOVE, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } + when { + uiState.isAllBlocksDownloaded -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.REMOVE, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } - !networkConnection.isOnline() -> { - val dialog = DownloadErrorDialogFragment.newInstance( - dialogType = DownloadErrorDialogType.NO_CONNECTION, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadErrorDialogFragment.DIALOG_TAG - ) - } + !networkConnection.isOnline() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.NO_CONNECTION, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } - StorageManager.getFreeStorage() < uiState.sizeSum -> { - val dialog = DownloadStorageErrorDialogFragment.newInstance( - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadStorageErrorDialogFragment.DIALOG_TAG - ) - } + StorageManager.getFreeStorage() < uiState.sizeSum -> { + val dialog = DownloadStorageErrorDialogFragment.newInstance( + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadStorageErrorDialogFragment.DIALOG_TAG + ) + } - corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { - val dialog = DownloadErrorDialogFragment.newInstance( - dialogType = DownloadErrorDialogType.WIFI_REQUIRED, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadErrorDialogFragment.DIALOG_TAG - ) - } + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.WIFI_REQUIRED, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } - !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() && uiState.sizeSum >= MAX_CELLURAL_SIZE -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() && uiState.sizeSum >= MAX_CELLULAR_SIZE -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } - else -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.CONFIRM, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } + else -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } } } } @@ -127,7 +128,9 @@ class DownloadDialogManager( val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } } - val size = blocks.mapNotNull { it.downloadModel?.size }.sum().toLong() + val size = blocks.sumOf { + getFileSize(it) * 2 + }.toLong() if (size > 0) { DownloadDialogItem(title = subSectionsBlock.displayName, size = size) } else { @@ -138,7 +141,7 @@ class DownloadDialogManager( DownloadDialogUIState( downloadDialogItems = downloadDialogItems, isAllBlocksDownloaded = isAllBlocksDownloaded, - sizeSum = downloadDialogItems.sumOf { it.size } * 2, + sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, removeDownloadModels = { subSectionsBlocks.forEach { @@ -154,4 +157,12 @@ class DownloadDialogManager( ) } } + + private fun getFileSize(block: Block): Long { + return when (block.type) { + BlockType.VIDEO -> block.downloadModel?.size ?: 0 + BlockType.HTML -> block.offlineDownload?.fileSize ?: 0 + else -> 0 + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 1f31b32de..5c004a226 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -52,6 +52,7 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode @@ -581,7 +582,8 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( id = "id", @@ -600,7 +602,8 @@ private val mockSequentialBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1) ) private val mockCourseStructure = CourseStructure( diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index f1ed2521d..539eef518 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -26,6 +26,7 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType @@ -61,12 +62,14 @@ class CourseOutlineViewModel( coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper ) { val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled @@ -390,74 +393,48 @@ class CourseOutlineViewModel( } } - fun findBlocksAtBlockLevel(blockData: List, rootBlockId: String): List { - val rootBlock = blockData.find { it.id == rootBlockId } ?: return emptyList() - val blockLevelBlocks = mutableListOf() - - fun dfs(block: Block) { - block.descendants.forEach { descendantId -> - val descendantBlock = blockData.find { it.id == descendantId } - if (descendantBlock != null) { - if (descendantBlock.descendants.isEmpty()) { - // Якщо поточний descendant блок не має нащадків, - // то додаємо його до списку blockLevelBlocks - blockLevelBlocks.add(descendantBlock) - } else { - // Інакше викликаємо dfs для рекурсивного перегляду нащадків descendant блоку - dfs(descendantBlock) - } + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null } - } - dfs(rootBlock) - return blockLevelBlocks - } + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { - viewModelScope.launch { - val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } - val downloadedBlocks = blocksIds.filter { isBlockDownloaded(it) } if (downloadingBlocks.isNotEmpty()) { - val downloadableChildren = downloadingBlocks.mapNotNull { getDownloadableChildren(it) }.flatten() - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - downloadableChildren - ) + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { - (_uiState.value as? CourseOutlineUIState.CourseData)?.let { courseData -> - val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } - downloadDialogManager.showPopup( - subSectionsBlocks = subSectionsBlocks, - courseId = courseId, - isAllBlocksDownloaded = downloadedBlocks.isNotEmpty(), - fragmentManager = fragmentManager, - removeDownloadModels = { blockId -> - removeDownloadModels(blockId) - }, - saveDownloadModels = { blockId -> - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId - ) - } - ) - } + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isAllBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(FileUtil(context).getExternalAppDir().path, blockId) + } + ) } } - -// blocksIds.forEach { blockId -> -// if (isBlockDownloading(blockId)) { -// courseRouter.navigateToDownloadQueue( -// fm = fragmentManager, -// getDownloadableChildren(blockId) ?: arrayListOf() -// ) -// } else if (isBlockDownloaded(blockId)) { -// removeDownloadModels(blockId) -// } else { -// saveDownloadModels( -// FileUtil(context).getExternalAppDir().path, blockId -// ) -// } -// } } } 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 0c83b264b..d281f1820 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 @@ -79,12 +79,10 @@ 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.core.ui.windowSizeValue import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow -import java.io.File import java.util.Date import org.openedx.core.R as CoreR @@ -482,5 +480,6 @@ private val mockBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = AssignmentProgress("", 1f, 2f), - due = Date() + due = Date(), + offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 33870c69c..b13e957f2 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -15,6 +15,7 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.ResourceManager @@ -37,12 +38,14 @@ class CourseSectionViewModel( coreAnalytics: CoreAnalytics, workerController: DownloadWorkerController, downloadDao: DownloadDao, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) 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 cef2592c2..8528fb74f 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 @@ -1270,6 +1270,7 @@ private fun OfflineQueueCardPreview() { Surface(color = MaterialTheme.appColors.background) { OfflineQueueCard( downloadModel = DownloadModel( + courseId = "", id = "", title = "Problems of society", size = 4000, @@ -1277,7 +1278,6 @@ private fun OfflineQueueCardPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), progressValue = 10, progressSize = 30, @@ -1325,5 +1325,6 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = AssignmentProgress("", 1f, 2f), - due = Date() + due = Date(), + offlineDownload = null ) 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 index 1a406181d..d60ec6091 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -718,7 +718,8 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( @@ -738,7 +739,8 @@ private val mockSequentialBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockCourseStructure = CourseStructure( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 6d37954ee..c0ed90e12 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -80,7 +80,21 @@ class CourseUnitContainerAdapter( block.isWordCloudBlock || block.isLTIConsumerBlock || block.isSurveyBlock -> { - HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) + val downloadedModel = viewModel.getDownloadModelById(block.id) + val offlineUrl = downloadedModel?.path ?: "" + val lastModified: String = + if (downloadedModel != null && !viewModel.hasNetworkConnection) { + downloadedModel.lastModified ?: "" + } else { + "" + } + HtmlUnitFragment.newInstance( + block.id, + block.studentViewUrl, + block.displayName, + offlineUrl, + lastModified + ) } else -> { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index af4d839e7..20c0c7c3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -18,6 +18,7 @@ import org.openedx.core.extension.indexOfFirstFromIndex import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated @@ -33,6 +34,7 @@ class CourseUnitContainerViewModel( private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { private val blocks = ArrayList() @@ -82,6 +84,9 @@ class CourseUnitContainerViewModel( private val _descendantsBlocks = MutableStateFlow>(listOf()) val descendantsBlocks = _descendantsBlocks.asStateFlow() + val hasNetworkConnection: Boolean + get() = networkConnection.isOnline() + fun loadBlocks(mode: CourseViewMode, componentId: String = "") { currentMode = mode viewModelScope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 392fa07fa..f7aa529ab 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,11 +68,19 @@ class HtmlUnitFragment : Fragment() { private val viewModel by viewModel() private var blockId: String = "" private var blockUrl: String = "" + private var offlineUrl: String = "" + private var blockTitle: String = "" + private var lastModified: String = "" + private var fromDownloadedContent: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) blockId = requireArguments().getString(ARG_BLOCK_ID, "") blockUrl = requireArguments().getString(ARG_BLOCK_URL, "") + offlineUrl = requireArguments().getString(ARG_OFFLINE_URL, "") + blockTitle = requireArguments().getString(ARG_BLOCK_TITLE, "") + lastModified = requireArguments().getString(ARG_LAST_MODIFIED, "") + fromDownloadedContent = lastModified.isNotEmpty() } override fun onCreateView( @@ -92,6 +101,16 @@ class HtmlUnitFragment : Fragment() { mutableStateOf(viewModel.isOnline) } + val url by rememberSaveable { + mutableStateOf( + if (!hasInternetConnection && offlineUrl.isNotEmpty()) { + offlineUrl + } else { + blockUrl + } + ) + } + val injectJSList by viewModel.injectJSList.collectAsState() val configuration = LocalConfiguration.current @@ -125,10 +144,10 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { + if (hasInternetConnection || fromDownloadedContent) { HTMLContentView( windowSize = windowSize, - url = blockUrl, + url = url, cookieManager = viewModel.cookieManager, apiHostURL = viewModel.apiHostURL, isLoading = isLoading, @@ -174,14 +193,23 @@ class HtmlUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_OFFLINE_URL = "offlineUrl" + private const val ARG_BLOCK_TITLE = "blockTitle" + private const val ARG_LAST_MODIFIED = "lastModified" fun newInstance( blockId: String, blockUrl: String, + blockTitle: String, + offlineUrl: String = "", + lastModified: String = "" ): HtmlUnitFragment { val fragment = HtmlUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_OFFLINE_URL to offlineUrl, + ARG_LAST_MODIFIED to lastModified, + ARG_BLOCK_TITLE to blockTitle ) return fragment } @@ -290,7 +318,8 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true - + allowFileAccess = true + allowContentAccess = true } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false 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 49f3b6120..4bc6fd600 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 @@ -19,6 +19,7 @@ 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 +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -47,13 +48,15 @@ class CourseVideoViewModel( val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, - workerController: DownloadWorkerController + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled 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 index 5e50ecf39..ea2b40e4b 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -223,6 +223,7 @@ private fun DownloadQueueScreenPreview() { uiState = DownloadQueueUIState.Models( listOf( DownloadModel( + courseId = "", id = "", title = "1", size = 0, @@ -230,9 +231,9 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), DownloadModel( + courseId = "", id = "", title = "2", size = 0, @@ -240,7 +241,6 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ) ), currentProgressId = "", 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 index 3b9f3d1aa..1c74e3b80 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -8,6 +8,7 @@ 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.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -19,7 +20,15 @@ class DownloadQueueViewModel( private val workerController: DownloadWorkerController, private val downloadNotifier: DownloadNotifier, coreAnalytics: CoreAnalytics, -) : BaseDownloadViewModel("", downloadDao, preferencesManager, workerController, coreAnalytics) { + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + "", + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) val uiState = _uiState.asStateFlow() From b92fc6aa20e0b136064a8d04eccc013d99ef7d5b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Jun 2024 22:32:23 +0300 Subject: [PATCH 06/23] feat: DownloadErrorDialog logic --- .../main/java/org/openedx/app/AppActivity.kt | 1 + .../main/java/org/openedx/app/AppViewModel.kt | 23 ++++- .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 16 ++- .../org/openedx/core/module/DownloadWorker.kt | 12 ++- .../core/system/notifier/DownloadFailed.kt | 7 ++ .../core/system/notifier/DownloadNotifier.kt | 1 + .../data/repository/CourseRepository.kt | 9 ++ .../domain/interactor/CourseInteractor.kt | 4 + .../download/DownloadConfirmDialogFragment.kt | 1 + .../download/DownloadDialogManager.kt | 97 +++++++++++++++---- .../download/DownloadDialogUIState.kt | 3 +- .../download/DownloadErrorDialogFragment.kt | 22 ++++- .../DownloadStorageErrorDialogFragment.kt | 1 + .../presentation/download/DownloadView.kt | 6 +- .../outline/CourseOutlineScreen.kt | 3 +- .../outline/CourseOutlineViewModel.kt | 21 +--- .../container/CourseUnitContainerAdapter.kt | 3 +- 18 files changed, 180 insertions(+), 52 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 9781b0ca7..4ea7b1884 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -73,6 +73,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.logAppLaunchEvent() setContentView(binding.root) val container = binding.rootLayout + viewModel.fragmentManager = supportFragmentManager container.addView(object : View(this) { override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 3fc49859f..fe9f03eca 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -16,17 +16,22 @@ import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.DownloadFailed +import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.utils.FileUtil +import org.openedx.course.presentation.download.DownloadDialogManager class AppViewModel( private val config: Config, - private val notifier: AppNotifier, + private val appNotifier: AppNotifier, private val room: RoomDatabase, private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, + private val downloadNotifier: DownloadNotifier, + private val downloadDialogManager: DownloadDialogManager, ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -40,6 +45,8 @@ class AppViewModel( val isBranchEnabled get() = config.getBranchConfig().enabled private val canResetAppDirectory get() = preferencesManager.canResetAppDirectory + var fragmentManager: FragmentManager? = null + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) setUserId() @@ -47,7 +54,7 @@ class AppViewModel( resetAppDirectory() } viewModelScope.launch { - notifier.notifier.collect { event -> + appNotifier.notifier.collect { event -> if (event is LogoutEvent && System.currentTimeMillis() - logoutHandledAt > 5000) { logoutHandledAt = System.currentTimeMillis() preferencesManager.clear() @@ -59,6 +66,18 @@ class AppViewModel( } } } + viewModelScope.launch { + downloadNotifier.notifier.collect { event -> + if (event is DownloadFailed) { + fragmentManager?.let { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = event.downloadModel, + fragmentManager = it, + ) + } + } + } + } } fun logAppLaunchEvent() { 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 b085e2b9c..8b6ef47fb 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -85,7 +85,7 @@ val appModule = module { single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get(), get()) } - single { DownloadDialogManager(get(), get(), get()) } + single { DownloadDialogManager(get(), get(), get(), get()) } single { ImageProcessor(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 7a01ea326..bcf64f62d 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -67,7 +67,20 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get(), get()) } + viewModel { + AppViewModel( + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } @@ -236,6 +249,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String) -> 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 55a40c49a..ecf7ddf51 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -22,6 +22,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged import org.openedx.core.utils.FileUtil @@ -31,10 +32,7 @@ class DownloadWorker( parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as - NotificationManager - + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) @@ -42,6 +40,7 @@ class DownloadWorker( private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private var downloadEnqueue = listOf() + private var downloadError = mutableListOf() private val folder = FileUtil(context).getExternalAppDir() @@ -59,7 +58,6 @@ class DownloadWorker( return Result.success() } - private fun createForegroundInfo(): ForegroundInfo { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel() @@ -140,9 +138,13 @@ class DownloadWorker( ) } else { downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) } newDownload() } else { + if (downloadError.isNotEmpty()) { + notifier.send(DownloadFailed(downloadError)) + } return } } diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt new file mode 100644 index 000000000..c5812f57f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt @@ -0,0 +1,7 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.module.db.DownloadModel + +data class DownloadFailed( + val downloadModel: List +) : 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 index eb16cf99f..9c0c698cf 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt @@ -11,5 +11,6 @@ class DownloadNotifier { val notifier: Flow = channel.asSharedFlow() suspend fun send(event: DownloadProgressChanged) = channel.emit(event) + suspend fun send(event: DownloadFailed) = channel.emit(event) } diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index e1be88b7a..ecc9d95d5 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -33,6 +33,15 @@ class CourseRepository( return courseStructure[courseId] != null } + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + suspend fun getCourseStructure(courseId: String, isNeedRefresh: Boolean): CourseStructure { if (!isNeedRefresh) courseStructure[courseId]?.let { return it } 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 5bc859120..4f3ca7d8d 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 @@ -16,6 +16,10 @@ class CourseInteractor( return repository.getCourseStructure(courseId, isNeedRefresh) } + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + return repository.getCourseStructureFromCache(courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 6eb44ff1d..4525190be 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -245,6 +245,7 @@ private fun DownloadConfirmDialogViewPreview() { ), sizeSum = 1000000, isAllBlocksDownloaded = false, + isDownloadFailed = false, saveDownloadModels = {}, removeDownloadModels = {}, fragmentManager = PreviewFragmentManager diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index da0b5727f..f6f12ef4a 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.launch 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.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.domain.interactor.CourseInteractor @@ -15,7 +17,8 @@ import org.openedx.course.domain.interactor.CourseInteractor class DownloadDialogManager( private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val interactor: CourseInteractor + private val interactor: CourseInteractor, + private val workerController: DownloadWorkerController ) { companion object { @@ -28,6 +31,17 @@ class DownloadDialogManager( CoroutineScope(Dispatchers.IO).launch { uiState.collect { uiState -> when { + uiState.isDownloadFailed -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + uiState.isAllBlocksDownloaded -> { val dialog = DownloadConfirmDialogFragment.newInstance( dialogType = DownloadConfirmDialogType.REMOVE, @@ -105,7 +119,7 @@ class DownloadDialogManager( removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { - getDownloadItems( + createDownloadItems( subSectionsBlocks = subSectionsBlocks, courseId = courseId, fragmentManager = fragmentManager, @@ -115,7 +129,63 @@ class DownloadDialogManager( ) } - private fun getDownloadItems( + fun showDownloadFailedPopup( + downloadModel: List, + fragmentManager: FragmentManager, + ) { + createDownloadItems( + downloadModel = downloadModel, + fragmentManager = fragmentManager, + ) + } + + private fun createDownloadItems( + downloadModel: List, + fragmentManager: FragmentManager, + ) { + CoroutineScope(Dispatchers.IO).launch { + val courseIds = downloadModel.map { it.courseId }.distinct() + val blockIds = downloadModel.map { it.id } + val notDownloadedSubSections = mutableListOf() + val allDownloadDialogItems = mutableListOf() + courseIds.forEach { courseId -> + val courseStructure = interactor.getCourseStructureFromCache(courseId) + val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + allSubSectionBlocks.forEach { subSectionsBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + val blocks = courseStructure.blockData.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds + } + val size = blocks.sumOf { getFileSize(it) * 2 } + if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionsBlock) + if (size > 0) { + val downloadDialogItem = DownloadDialogItem( + title = subSectionsBlock.displayName, + size = size + ) + allDownloadDialogItems.add(downloadDialogItem) + } + } + } + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = allDownloadDialogItems, + isAllBlocksDownloaded = false, + isDownloadFailed = true, + sizeSum = allDownloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = {}, + saveDownloadModels = { + CoroutineScope(Dispatchers.IO).launch { + workerController.saveModels(downloadModel) + } + } + ) + ) + } + } + + private fun createDownloadItems( subSectionsBlocks: List, courseId: String, fragmentManager: FragmentManager, @@ -128,36 +198,29 @@ class DownloadDialogManager( val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } } - val size = blocks.sumOf { - getFileSize(it) * 2 - }.toLong() - if (size > 0) { - DownloadDialogItem(title = subSectionsBlock.displayName, size = size) - } else { - null - } + val size = blocks.sumOf { getFileSize(it) * 2 } + if (size > 0) DownloadDialogItem(title = subSectionsBlock.displayName, size = size) else null } + uiState.emit( DownloadDialogUIState( downloadDialogItems = downloadDialogItems, isAllBlocksDownloaded = isAllBlocksDownloaded, + isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, removeDownloadModels = { - subSectionsBlocks.forEach { - removeDownloadModels(it.id) - } + subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, saveDownloadModels = { - subSectionsBlocks.forEach { - saveDownloadModels(it.id) - } + subSectionsBlocks.forEach { saveDownloadModels(it.id) } } ) ) } } + private fun getFileSize(block: Block): Long { return when (block.type) { BlockType.VIDEO -> block.downloadModel?.size ?: 0 diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt index 59bfba0b4..95703d06d 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -10,7 +10,8 @@ data class DownloadDialogUIState( val downloadDialogItems: List = emptyList(), val sizeSum: Long, val isAllBlocksDownloaded: Boolean, - var fragmentManager: @RawValue FragmentManager, + val isDownloadFailed: Boolean, + val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, val saveDownloadModels: () -> Unit ) : Parcelable \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 74102e443..2e180b99f 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -33,6 +33,7 @@ import org.openedx.core.extension.parcelable import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -78,6 +79,11 @@ class DownloadErrorDialogFragment : DialogFragment() { DownloadErrorDialogView( downloadDialogResource = downloadDialogResource, uiState = uiState, + dialogType = dialogType, + onTryAgainClick = { + uiState.saveDownloadModels() + dismiss() + }, onCancelClick = { dismiss() } @@ -110,7 +116,9 @@ private fun DownloadErrorDialogView( modifier: Modifier = Modifier, uiState: DownloadDialogUIState, downloadDialogResource: DownloadDialogResource, - onCancelClick: () -> Unit + dialogType: DownloadErrorDialogType, + onTryAgainClick: () -> Unit, + onCancelClick: () -> Unit, ) { val scrollState = rememberScrollState() DefaultDialogBox( @@ -152,6 +160,13 @@ private fun DownloadErrorDialogView( style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark ) + if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { + OpenEdXButton( + text = stringResource(id = coreR.string.core_error_try_again), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onTryAgainClick, + ) + } OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = org.openedx.core.R.string.core_cancel), @@ -189,11 +204,14 @@ private fun DownloadErrorDialogViewPreview() { ), sizeSum = 100000, isAllBlocksDownloaded = false, + isDownloadFailed = false, fragmentManager = PreviewFragmentManager, removeDownloadModels = {}, saveDownloadModels = {} ), - onCancelClick = {} + onCancelClick = {}, + onTryAgainClick = {}, + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index f243e192a..c6b191beb 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -267,6 +267,7 @@ private fun DownloadStorageErrorDialogViewPreview() { ), sizeSum = 100000, isAllBlocksDownloaded = false, + isDownloadFailed = false, fragmentManager = PreviewFragmentManager, removeDownloadModels = {}, saveDownloadModels = {} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt index e22a0a841..9f64e015e 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import org.openedx.core.R @@ -20,7 +21,8 @@ import org.openedx.core.ui.theme.appTypography fun DownloadDialogItem( modifier: Modifier = Modifier, title: String, - size: String + size: String, + icon: Painter = painterResource(id = R.drawable.ic_core_chapter_icon) ) { Row( modifier = modifier.padding(vertical = 6.dp), @@ -28,7 +30,7 @@ fun DownloadDialogItem( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_core_chapter_icon), + painter = icon, tint = MaterialTheme.appColors.textDark, contentDescription = null, modifier = Modifier.size(24.dp) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 5c004a226..720430400 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -140,8 +140,7 @@ fun CourseOutlineScreen( onDownloadClick = { blocksIds -> viewModel.downloadBlocks( blocksIds = blocksIds, - fragmentManager = fragmentManager, - context = context + fragmentManager = fragmentManager ) }, onResetDatesClick = { diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 539eef518..53dfc6eea 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.course.presentation.outline -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -45,7 +44,6 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager -import org.openedx.course.R as courseR class CourseOutlineViewModel( val courseId: String, @@ -58,6 +56,7 @@ class CourseOutlineViewModel( private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, @@ -131,20 +130,6 @@ class CourseOutlineViewModel( getCourseData() } - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - viewModelScope.launch { - _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) - } - } - } else { - super.saveDownloadModels(folder, id) - } - } - fun updateCourseData() { getCourseDataInternal() } @@ -393,7 +378,7 @@ class CourseOutlineViewModel( } } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch @@ -431,7 +416,7 @@ class CourseOutlineViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(FileUtil(context).getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) } ) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index c0ed90e12..a36c4381e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -10,6 +10,7 @@ import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import java.io.File class CourseUnitContainerAdapter( fragment: Fragment, @@ -81,7 +82,7 @@ class CourseUnitContainerAdapter( block.isLTIConsumerBlock || block.isSurveyBlock -> { val downloadedModel = viewModel.getDownloadModelById(block.id) - val offlineUrl = downloadedModel?.path ?: "" + val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" val lastModified: String = if (downloadedModel != null && !viewModel.hasNetworkConnection) { downloadedModel.lastModified ?: "" From f5db5c59be04e1f86c5769c0b9b7f6a603c2db20 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Jun 2024 17:58:11 +0300 Subject: [PATCH 07/23] feat: updateOutdatedOfflineXBlocks --- .../test/java/org/openedx/AppViewModelTest.kt | 20 +++- .../org/openedx/core/domain/model/Block.kt | 22 ++-- .../org/openedx/core/module/DownloadWorker.kt | 2 +- .../core/module/DownloadWorkerController.kt | 4 +- .../org/openedx/core/module/db/DownloadDao.kt | 11 +- .../openedx/core/module/db/DownloadModel.kt | 2 +- .../module/download/BaseDownloadViewModel.kt | 6 +- .../core/module/download/DownloadHelper.kt | 6 +- .../data/repository/CourseRepository.kt | 6 +- .../domain/interactor/CourseInteractor.kt | 1 + .../outline/CourseOutlineViewModel.kt | 31 +++++ .../outline/CourseOutlineViewModelTest.kt | 109 +++++++++--------- .../section/CourseSectionViewModelTest.kt | 35 ++++-- .../CourseUnitContainerViewModelTest.kt | 34 +++--- .../videos/CourseVideoViewModelTest.kt | 46 +++++--- .../topics/DiscussionTopicsViewModelTest.kt | 35 +----- 16 files changed, 212 insertions(+), 158 deletions(-) diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 35a2d3d96..4bdc8e1f8 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -21,15 +21,17 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.system.notifier.AppNotifier import org.openedx.app.system.notifier.LogoutEvent import org.openedx.core.config.Config import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.utils.FileUtil +import org.openedx.course.presentation.download.DownloadDialogManager @ExperimentalCoroutinesApi class AppViewModelTest { @@ -46,12 +48,16 @@ class AppViewModelTest { private val analytics = mockk() private val fileUtil = mockk() private val deepLinkRouter = mockk() + private val downloadDialogManager = mockk() + private val downloadNotifier = mockk() private val user = User(0, "", "", "") @Before fun before() { Dispatchers.setMain(dispatcher) + every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit + every { downloadNotifier.notifier } returns flow { } } @After @@ -74,7 +80,9 @@ class AppViewModelTest { dispatcher, analytics, deepLinkRouter, - fileUtil + fileUtil, + downloadNotifier, + downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -106,7 +114,9 @@ class AppViewModelTest { dispatcher, analytics, deepLinkRouter, - fileUtil + fileUtil, + downloadNotifier, + downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -140,7 +150,9 @@ class AppViewModelTest { dispatcher, analytics, deepLinkRouter, - fileUtil + fileUtil, + downloadNotifier, + downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 63fc306ce..3ebf8c8b6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -36,21 +36,19 @@ data class Block( ) : Parcelable { val isDownloadable: Boolean get() { - return (studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true) || - (!offlineDownload?.fileUrl.isNullOrEmpty()) + return (studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true) || isxBlock } - val downloadableType: FileType? - get() = when (type) { - BlockType.VIDEO -> { - FileType.VIDEO - } - - BlockType.HTML -> { - FileType.HTML - } + val isxBlock: Boolean + get() = !offlineDownload?.fileUrl.isNullOrEmpty() - else -> null + val downloadableType: FileType? + get() = if (type == BlockType.VIDEO) { + FileType.VIDEO + } else if (isxBlock) { + FileType.X_BLOCK + } else { + null } fun isDownloading(): Boolean { 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 ecf7ddf51..4103257c9 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -115,7 +115,7 @@ class DownloadWorker( folder.mkdir() } - downloadEnqueue = downloadDao.readAllData().first() + downloadEnqueue = downloadDao.getAllDataFlow().first() .map { it.mapToDomain() } .filter { it.downloadedState == DownloadedState.WAITING } 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 a4e83c07e..2bcff0b06 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -28,7 +28,7 @@ class DownloadWorkerController( init { GlobalScope.launch { - downloadDao.readAllData().collect { list -> + downloadDao.getAllDataFlow().collect { list -> val domainList = list.map { it.mapToDomain() } downloadTaskList = domainList.filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING @@ -47,7 +47,7 @@ class DownloadWorkerController( private suspend fun updateList() { downloadTaskList = - downloadDao.readAllData().first().map { it.mapToDomain() }.filter { + downloadDao.getAllDataFlow().first().map { it.mapToDomain() }.filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING } } 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 5bdfc637b..f1f6c7ca4 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 @@ -1,6 +1,10 @@ package org.openedx.core.module.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao @@ -16,7 +20,10 @@ interface DownloadDao { suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @Query("SELECT * FROM download_model") - fun readAllData() : Flow> + fun getAllDataFlow(): Flow> + + @Query("SELECT * FROM download_model") + suspend fun readAllData(): List @Query("SELECT * FROM download_model WHERE id in (:ids)") fun readAllDataByIds(ids: List) : Flow> diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index 016466a9e..3f2c233fe 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -31,5 +31,5 @@ enum class DownloadedState { } enum class FileType { - VIDEO, HTML + VIDEO, X_BLOCK } \ No newline at end of file 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 f960bbecf..4d90a1b2d 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 @@ -41,7 +41,7 @@ abstract class BaseDownloadViewModel( init { viewModelScope.launch { - downloadDao.readAllData().map { list -> list.map { it.mapToDomain() } } + downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } .collect { downloadModels -> updateDownloadModelsStatus(downloadModels) _downloadModelsStatusFlow.emit(downloadModelsStatus) @@ -55,7 +55,7 @@ abstract class BaseDownloadViewModel( } private suspend fun getDownloadModelList(): List { - return downloadDao.readAllData().first().map { it.mapToDomain() } + return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } private suspend fun updateDownloadModelsStatus(models: List) { @@ -120,7 +120,7 @@ abstract class BaseDownloadViewModel( } } - private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt index 72c39fb43..c3ce97700 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -43,8 +43,8 @@ class DownloadHelper( ) } - FileType.HTML -> { - val url = if (block.downloadableType == FileType.HTML) { + FileType.X_BLOCK -> { + val url = if (block.downloadableType == FileType.X_BLOCK) { block.offlineDownload?.fileUrl ?: "" } else { "" @@ -80,7 +80,7 @@ class DownloadHelper( ) } - FileType.HTML -> { + FileType.X_BLOCK -> { val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) downloadModel.copy( downloadedState = DownloadedState.DOWNLOADED, diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index ecc9d95d5..63585e34e 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -25,13 +25,11 @@ class CourseRepository( downloadDao.removeDownloadModel(id) } - fun getDownloadModels() = downloadDao.readAllData().map { list -> + fun getDownloadModels() = downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } - fun hasCourses(courseId: String): Boolean { - return courseStructure[courseId] != null - } + suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { val cachedCourseStructure = courseDao.getCourseStructureById(courseId) 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 4f3ca7d8d..c201afa91 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 @@ -76,4 +76,5 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() + suspend fun getAllDownloadModels() = repository.getAllDownloadModels() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 53dfc6eea..e3ad2103c 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -19,6 +19,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -93,6 +94,8 @@ class CourseOutlineViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() + private var isOfflineBlocksUpToDate = false + init { viewModelScope.launch { courseNotifier.notifier.collect { event -> @@ -193,6 +196,7 @@ class CourseOutlineViewModel( val datesBannerInfo = courseDatesResult.courseBanner checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) setBlocks(blocks) courseSubSections.clear() @@ -422,4 +426,31 @@ class CourseOutlineViewModel( } } } + + private fun updateOutdatedOfflineXBlocks(courseStructure: CourseStructure) { + viewModelScope.launch { + if (!isOfflineBlocksUpToDate) { + val xBlocks = courseStructure.blockData.filter { it.isxBlock } + if (xBlocks.isNotEmpty()) { + val xBlockIds = xBlocks.map { it.id }.toSet() + val savedDownloadModelsMap = interactor.getAllDownloadModels() + .filter { it.id in xBlockIds } + .associateBy { it.id } + + val outdatedBlockIds = xBlocks + .filter { block -> + val savedBlock = savedDownloadModelsMap[block.id] + savedBlock != null && block.offlineDownload?.lastModified != savedBlock.lastModified + } + .map { it.id } + + outdatedBlockIds.forEach { blockId -> + interactor.removeDownloadModel(blockId) + } + saveDownloadModels(fileUtil.getExternalAppDir().path, outdatedBlockIds) + } + isOfflineBlocksUpToDate = true + } + } + } } 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 aad650b28..1baf0d76e 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 @@ -49,15 +49,18 @@ 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.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent 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.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager import java.net.UnknownHostException import java.util.Date @@ -80,6 +83,9 @@ class CourseOutlineViewModelTest { private val analytics = mockk() private val coreAnalytics = mockk() private val courseRouter = mockk() + private val fileUtil = mockk() + private val downloadDialogManager = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -108,7 +114,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -126,7 +133,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -144,7 +152,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -208,6 +217,7 @@ class CourseOutlineViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -223,6 +233,7 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload every { config.getApiHostURL() } returns "http://localhost:8000" + every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } @@ -236,7 +247,8 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( @@ -249,10 +261,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, workerController, + downloadHelper, ) val message = async { @@ -272,7 +287,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", @@ -284,10 +299,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -307,7 +325,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -329,10 +347,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -355,7 +376,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns false - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -377,10 +398,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -402,7 +426,7 @@ class CourseOutlineViewModelTest { fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -424,10 +448,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -448,7 +475,7 @@ class CourseOutlineViewModelTest { @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseOutlineViewModel( "", "", @@ -459,10 +486,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } coEvery { interactor.getCourseStructure(any()) } returns courseStructure @@ -495,7 +525,7 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( @@ -508,10 +538,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -538,7 +571,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -552,46 +585,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() - - assert(message.await()?.message.isNullOrEmpty()) - } - - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - courseRouter, - coreAnalytics, - downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -599,7 +599,6 @@ class CourseOutlineViewModelTest { } } viewModel.saveDownloadModels("", "") - advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) 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 01c685c48..dd2559dc4 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 @@ -38,6 +38,7 @@ 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.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.ResourceManager @@ -65,6 +66,7 @@ class CourseSectionViewModelTest { private val notifier = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -93,7 +95,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -111,7 +114,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -129,7 +133,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -161,6 +166,7 @@ class CourseSectionViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -184,7 +190,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -196,6 +202,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() @@ -214,7 +221,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -226,6 +233,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) coEvery { interactor.getCourseStructure(any()) } throws Exception() @@ -244,7 +252,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( @@ -258,9 +266,10 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } coEvery { interactor.getCourseStructure(any()) } returns courseStructure @@ -278,7 +287,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( @@ -292,6 +301,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true @@ -306,7 +316,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( @@ -320,6 +330,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true @@ -334,7 +345,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -346,6 +357,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false @@ -362,7 +374,7 @@ class CourseSectionViewModelTest { @Test fun `updateVideos success`() = runTest { - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -379,6 +391,7 @@ class CourseSectionViewModelTest { coreAnalytics, workerController, downloadDao, + downloadHelper, ) coEvery { notifier.notifier } returns flow { } diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 166d7751e..a63cbddf7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -26,6 +26,7 @@ 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.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -44,6 +45,7 @@ class CourseUnitContainerViewModelTest { private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() + private val networkConnection = mockk() private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", @@ -68,7 +70,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -86,7 +89,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -104,7 +108,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id3", @@ -122,7 +127,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -166,7 +172,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() @@ -181,7 +187,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() @@ -196,7 +202,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -213,7 +219,7 @@ class CourseUnitContainerViewModelTest { fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -228,7 +234,7 @@ class CourseUnitContainerViewModelTest { fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -245,7 +251,7 @@ class CourseUnitContainerViewModelTest { fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -262,7 +268,7 @@ class CourseUnitContainerViewModelTest { fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -279,7 +285,7 @@ class CourseUnitContainerViewModelTest { fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -296,7 +302,7 @@ class CourseUnitContainerViewModelTest { fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure("") } returns courseStructure coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure @@ -313,7 +319,7 @@ class CourseUnitContainerViewModelTest { fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure 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 9bb8d0f5f..c814d83c8 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 @@ -44,6 +44,7 @@ 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.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -76,6 +77,7 @@ class CourseVideoViewModelTest { private val downloadDao = mockk() private val workerController = mockk() private val courseRouter = mockk() + private val downloadHelper = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -102,7 +104,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -120,7 +123,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -138,7 +142,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -168,11 +173,12 @@ class CourseVideoViewModelTest { ) private val downloadModelEntity = - DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + DownloadModelEntity("", "", "", 1, "", "", "VIDEO", "DOWNLOADED", null) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -200,7 +206,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure.copy(blockData = emptyList()) - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -217,6 +223,7 @@ class CourseVideoViewModelTest { coreAnalytics, downloadDao, workerController, + downloadHelper, ) viewModel.getVideos() @@ -231,7 +238,7 @@ class CourseVideoViewModelTest { fun `getVideos success`() = runTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( @@ -248,7 +255,8 @@ class CourseVideoViewModelTest { courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) @@ -267,7 +275,7 @@ class CourseVideoViewModelTest { coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -288,7 +296,8 @@ class CourseVideoViewModelTest { courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -308,7 +317,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() } @@ -330,10 +339,11 @@ class CourseVideoViewModelTest { courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit @@ -367,14 +377,15 @@ class CourseVideoViewModelTest { courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -408,13 +419,14 @@ class CourseVideoViewModelTest { courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit val message = async { withTimeoutOrNull(5000) { diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 9fc56f6af..29a38a6a9 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -29,8 +29,6 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress 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.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -80,7 +78,8 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -98,7 +97,8 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -116,33 +116,10 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) - private val courseStructure = CourseStructure( - root = "", - blockData = blocks, - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = null - ) @Before fun setUp() { From 29dce552de50f0977b0cc66e6370fb78759683d2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Sat, 15 Jun 2024 14:09:26 +0300 Subject: [PATCH 08/23] feat: Progress of downloaded blocks --- .../java/org/openedx/app/di/ScreenModule.kt | 14 + .../org/openedx/core/ui/theme/Colors.kt | 2 +- .../container/CourseContainerFragment.kt | 16 ++ .../container/CourseContainerTab.kt | 3 + .../container/CourseContainerViewModel.kt | 1 + .../presentation/dates/CourseDatesScreen.kt | 14 +- .../presentation/dates/CourseDatesUIState.kt | 12 + .../dates/CourseDatesViewModel.kt | 12 +- .../presentation/dates/DashboardUIState.kt | 12 - .../download/DownloadConfirmDialogFragment.kt | 12 +- .../offline/CourseOfflineScreen.kt | 249 ++++++++++++++++++ .../offline/CourseOfflineUIState.kt | 8 + .../offline/CourseOfflineViewModel.kt | 96 +++++++ .../section/CourseSectionFragment.kt | 10 +- .../course/presentation/ui/CourseUI.kt | 18 +- .../drawable/course_ic_remove_download.xml | 37 --- .../res/drawable/course_ic_start_download.xml | 9 - course/src/main/res/values/strings.xml | 7 + .../dates/CourseDatesViewModelTest.kt | 8 +- .../openedx/courses/presentation/CourseTab.kt | 2 +- 20 files changed, 446 insertions(+), 96 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt delete mode 100644 course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt delete mode 100644 course/src/main/res/drawable/course_ic_remove_download.xml delete mode 100644 course/src/main/res/drawable/course_ic_start_download.xml 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 bcf64f62d..4270e2bb0 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -19,6 +19,7 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -425,4 +426,17 @@ val screenModule = module { viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String, courseTitle: String) -> + CourseOfflineViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + ) + } + } diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index ffc0b64e2..c0c91c975 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -73,7 +73,7 @@ val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White val light_progress_bar_color = light_primary -val light_progress_bar_background_color = Color(0xFF97A5BB) +val light_progress_bar_background_color = Color(0xFFCCD4E0) val dark_primary = Color(0xFF3F68F8) 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 49d6b8cae..6386aaf88 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 @@ -70,6 +70,7 @@ import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -506,6 +507,21 @@ fun DashboardPager( ) } + CourseContainerTab.OFFLINE -> { + CourseOfflineScreen( + windowSize = windowSize, + viewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + fragmentManager = fragmentManager, + ) + } + CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( discussionTopicsViewModel = koinViewModel( diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index fbdbb60fc..fa0cf6c8e 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -6,10 +6,12 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem import org.openedx.course.R +import org.openedx.core.R as coreR enum class CourseContainerTab( @StringRes @@ -19,6 +21,7 @@ enum class CourseContainerTab( HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), + OFFLINE(coreR.string.core_offline, Icons.Outlined.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 97045561e..0d4cc60b0 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -272,6 +272,7 @@ class CourseContainerViewModel( CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() + CourseContainerTab.OFFLINE -> {} } } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 7381402b2..6da13e866 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -99,7 +99,7 @@ fun CourseDatesScreen( isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) + val uiState by viewModel.uiState.observeAsState(CourseDatesUIState.Loading) val uiMessage by viewModel.uiMessage.collectAsState(null) val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current @@ -176,7 +176,7 @@ fun CourseDatesScreen( @Composable private fun CourseDatesUI( windowSize: WindowSize, - uiState: DatesUIState, + uiState: CourseDatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, calendarSyncUIState: CalendarSyncUIState, @@ -227,7 +227,7 @@ private fun CourseDatesUI( .fillMaxWidth() ) { when (uiState) { - is DatesUIState.Dates -> { + is CourseDatesUIState.CourseDates -> { LazyColumn( modifier = Modifier .fillMaxSize() @@ -294,7 +294,7 @@ private fun CourseDatesUI( } } - DatesUIState.Empty -> { + CourseDatesUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -309,7 +309,7 @@ private fun CourseDatesUI( } } - DatesUIState.Loading -> {} + CourseDatesUIState.Loading -> {} } } } @@ -639,7 +639,7 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = CourseDatesUIState.CourseDates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, isSelfPaced = true, calendarSyncUIState = mockCalendarSyncUIState, @@ -658,7 +658,7 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = CourseDatesUIState.CourseDates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, isSelfPaced = true, calendarSyncUIState = mockCalendarSyncUIState, diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt new file mode 100644 index 000000000..2a029c19c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDatesResult + +sealed interface CourseDatesUIState { + data class CourseDates( + val courseDatesResult: CourseDatesResult, + ) : CourseDatesUIState + + data object Empty : CourseDatesUIState + data object Loading : CourseDatesUIState +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 4d6236b67..d10dafc49 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -56,8 +56,8 @@ class CourseDatesViewModel( var isSelfPaced = true - private val _uiState = MutableLiveData(DatesUIState.Loading) - val uiState: LiveData + private val _uiState = MutableLiveData(CourseDatesUIState.Loading) + val uiState: LiveData get() = _uiState private val _uiMessage = MutableSharedFlow() @@ -105,9 +105,9 @@ class CourseDatesViewModel( isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { - _uiState.value = DatesUIState.Empty + _uiState.value = CourseDatesUIState.Empty } else { - _uiState.value = DatesUIState.Dates(datesResponse) + _uiState.value = CourseDatesUIState.CourseDates(datesResponse) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } @@ -180,7 +180,7 @@ class CourseDatesViewModel( private fun setCalendarSyncDialogType(dialog: CalendarSyncDialogType) { val value = _uiState.value - if (value is DatesUIState.Dates) { + if (value is CourseDatesUIState.CourseDates) { viewModelScope.launch { courseNotifier.send( CreateCalendarSyncEvent( @@ -195,7 +195,7 @@ class CourseDatesViewModel( private fun checkIfCalendarOutOfDate() { val value = _uiState.value - if (value is DatesUIState.Dates) { + if (value is CourseDatesUIState.CourseDates) { viewModelScope.launch { courseNotifier.send( CreateCalendarSyncEvent( diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt deleted file mode 100644 index 8ff75239f..000000000 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.course.presentation.dates - -import org.openedx.core.domain.model.CourseDatesResult - -sealed class DatesUIState { - data class Dates( - val courseDatesResult: CourseDatesResult, - ) : DatesUIState() - - object Empty : DatesUIState() - object Loading : DatesUIState() -} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 4525190be..f9ddb7bd2 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -19,12 +19,12 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -178,20 +178,20 @@ private fun DownloadConfirmDialogView( ) val buttonText: String - val buttonIcon: Painter + val buttonIcon: ImageVector val buttonColor: ComposeColor val onClick: () -> Unit when (dialogType) { DownloadConfirmDialogType.REMOVE -> { buttonText = stringResource(id = R.string.course_remove) - buttonIcon = rememberVectorPainter(Icons.Rounded.Delete) + buttonIcon = Icons.Rounded.Delete buttonColor = MaterialTheme.appColors.error onClick = onRemoveClick } else -> { buttonText = stringResource(id = R.string.course_download) - buttonIcon = painterResource(id = R.drawable.course_ic_start_download) + buttonIcon = Icons.Outlined.CloudDownload buttonColor = MaterialTheme.appColors.secondaryButtonBackground onClick = onConfirmClick } @@ -203,7 +203,7 @@ private fun DownloadConfirmDialogView( content = { IconText( text = buttonText, - painter = buttonIcon, + icon = buttonIcon, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt new file mode 100644 index 000000000..1187ebe6d --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -0,0 +1,249 @@ +package org.openedx.course.presentation.offline + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload +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.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.displayCutoutForLandscape +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.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.course.R + +@Composable +fun CourseOfflineScreen( + windowSize: WindowSize, + viewModel: CourseOfflineViewModel, + fragmentManager: FragmentManager, +) { + val uiState by viewModel.uiState.collectAsState() + + CourseOfflineUI( + windowSize = windowSize, + uiState = uiState, + onDownloadAllClick = { + + } + ) +} + +@Composable +private fun CourseOfflineUI( + windowSize: WindowSize, + uiState: CourseOfflineUIState, + onDownloadAllClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val horizontalPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = modifierScreenWidth, + color = MaterialTheme.appColors.background, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .then(horizontalPadding) + ) { + if (uiState.isHaveDownloadableBlocks) { + DownloadProgress(uiState = uiState) + } else { + NoDownloadableBlocksProgress() + } + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXButton( + text = stringResource(R.string.course_download_all), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onDownloadAllClick, + enabled = uiState.isHaveDownloadableBlocks, + content = { + val textColor = if (uiState.isHaveDownloadableBlocks) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.textPrimaryVariant + } + IconText( + text = stringResource(R.string.course_download_all), + icon = Icons.Outlined.CloudDownload, + color = textColor, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + } + } +} + +@Composable +private fun DownloadProgress( + modifier: Modifier = Modifier, + uiState: CourseOfflineUIState, +) { + Column( + modifier = modifier + ) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uiState.downloadedSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.successGreen + ) + Text( + text = uiState.readyToDownloadSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconText( + text = stringResource(R.string.course_downloaded), + icon = Icons.Default.CloudDone, + color = MaterialTheme.appColors.successGreen, + textStyle = MaterialTheme.appTypography.labelLarge + ) + IconText( + text = stringResource(R.string.course_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + if (uiState.progressBarValue != 0f) { + Spacer(modifier = Modifier.height(20.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape), + progress = uiState.progressBarValue, + strokeCap = StrokeCap.Round, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.course_you_can_download_course_content_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } +} + +@Composable +private fun NoDownloadableBlocksProgress( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.course_0mb), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textFieldHint + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + text = stringResource(R.string.course_available_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textFieldHint, + textStyle = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.course_no_available_to_download_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } +} + +@Preview +@Composable +private fun CourseOfflineUIPreview() { + OpenEdXTheme { + CourseOfflineUI( + windowSize = rememberWindowSize(), + uiState = CourseOfflineUIState( + isHaveDownloadableBlocks = true, + readyToDownloadSize = "159MB", + downloadedSize = "0MB", + progressBarValue = 0f + ), + onDownloadAllClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt new file mode 100644 index 000000000..79260561f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -0,0 +1,8 @@ +package org.openedx.course.presentation.offline + +data class CourseOfflineUIState( + val isHaveDownloadableBlocks: Boolean, + val readyToDownloadSize: String, + val downloadedSize: String, + val progressBarValue: Float +) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt new file mode 100644 index 000000000..032af3d63 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -0,0 +1,96 @@ +package org.openedx.course.presentation.offline + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.course.domain.interactor.CourseInteractor + +class CourseOfflineViewModel( + val courseId: String, + val courseTitle: String, + val courseInteractor: CourseInteractor, + private val preferencesManager: CorePreferences, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + private val _uiState = MutableStateFlow( + CourseOfflineUIState( + isHaveDownloadableBlocks = false, + readyToDownloadSize = "", + downloadedSize = "", + progressBarValue = 0f + ) + ) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + getOfflineData() + } + + private fun getOfflineData() { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructure(courseId) + val downloadableFilesSize = getFilesSize(courseStructure.blockData) + if (downloadableFilesSize == 0L) return@launch + + courseInteractor.getDownloadModels().collect { downloadModels -> + val downloadedModelsIds = downloadModels + .filter { it.downloadedState.isDownloaded && it.courseId == courseId } + .map { it.id } + val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } + val downloadedFilesSize = getFilesSize(downloadedBlocks) + + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(0, false), + downloadedSize = downloadedFilesSize.toFileSize(0, false), + progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() + ) + } + } + } + } + + private fun getFilesSize(block: List): Long { + return block.filter { it.isDownloadable }.sumOf { + when (it.downloadableType) { + FileType.VIDEO -> { + val videoInfo = + it.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + videoInfo?.fileSize ?: 0 + } + + FileType.X_BLOCK -> { + it.offlineDownload?.fileSize ?: 0 + } + + null -> 0 + } + } + } +} 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 d281f1820..1da340e20 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 @@ -32,6 +32,8 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -353,10 +355,10 @@ private fun CourseSubsectionItem( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -367,7 +369,7 @@ private fun CourseSubsectionItem( IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) 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 8528fb74f..25bc85637 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 @@ -47,6 +47,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -63,7 +64,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -160,10 +160,10 @@ fun CourseSectionCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -174,7 +174,7 @@ fun CourseSectionCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) @@ -678,11 +678,11 @@ fun CourseExpandableChapterCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { - rememberVectorPainter(Icons.Default.CloudDone) + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -699,7 +699,7 @@ fun CourseExpandableChapterCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick() }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = downloadIconTint ) diff --git a/course/src/main/res/drawable/course_ic_remove_download.xml b/course/src/main/res/drawable/course_ic_remove_download.xml deleted file mode 100644 index 6fa45832e..000000000 --- a/course/src/main/res/drawable/course_ic_remove_download.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/course/src/main/res/drawable/course_ic_start_download.xml b/course/src/main/res/drawable/course_ic_start_download.xml deleted file mode 100644 index 67d565694..000000000 --- a/course/src/main/res/drawable/course_ic_start_download.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index a0cda4fa4..2bb6f3154 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -78,6 +78,13 @@ Device Storage Full Your device does not have enough free space to download this content. Please free up some space and try again. %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. %1$s of %2$s assignments complete diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 11ffb4932..da10fa4b9 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -187,7 +187,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Loading) } @Test @@ -216,7 +216,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Loading) } @Test @@ -245,7 +245,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Dates) + assert(viewModel.uiState.value is CourseDatesUIState.CourseDates) } @Test @@ -277,6 +277,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Empty) + assert(viewModel.uiState.value is CourseDatesUIState.Empty) } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt index f0da7c186..f29e0a110 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt @@ -1,5 +1,5 @@ package org.openedx.courses.presentation enum class CourseTab { - HOME, VIDEOS, DATES, DISCUSSIONS, MORE + HOME, VIDEOS, DATES, OFFLINE, DISCUSSIONS, MORE } From f100aeb6fdea8a5b7bb81109a9afdd5d09f2b673 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Sat, 15 Jun 2024 17:32:53 +0300 Subject: [PATCH 09/23] feat: Download all button --- .../java/org/openedx/app/di/ScreenModule.kt | 3 + .../offline/CourseOfflineScreen.kt | 126 ++++++++++++------ .../offline/CourseOfflineUIState.kt | 1 + .../offline/CourseOfflineViewModel.kt | 57 +++++++- .../section/CourseSectionFragment.kt | 8 +- course/src/main/res/values/strings.xml | 1 + 6 files changed, 156 insertions(+), 40 deletions(-) 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 4270e2bb0..ea2d1e116 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -436,6 +436,9 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), ) } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index 1187ebe6d..e1dcd19ad 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -8,15 +8,21 @@ 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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState @@ -28,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -57,7 +64,10 @@ fun CourseOfflineScreen( windowSize = windowSize, uiState = uiState, onDownloadAllClick = { - + viewModel.downloadAllBlocks(fragmentManager) + }, + onStopDownloadClick = { + viewModel.navigateToDownloadQueue(fragmentManager) } ) } @@ -66,7 +76,8 @@ fun CourseOfflineScreen( private fun CourseOfflineUI( windowSize: WindowSize, uiState: CourseOfflineUIState, - onDownloadAllClick: () -> Unit + onDownloadAllClick: () -> Unit, + onStopDownloadClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -110,30 +121,35 @@ private fun CourseOfflineUI( .then(horizontalPadding) ) { if (uiState.isHaveDownloadableBlocks) { - DownloadProgress(uiState = uiState) + DownloadProgress( + uiState = uiState, + onStopDownloadClick = onStopDownloadClick, + ) } else { NoDownloadableBlocksProgress() } - Spacer(modifier = Modifier.height(20.dp)) - OpenEdXButton( - text = stringResource(R.string.course_download_all), - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = onDownloadAllClick, - enabled = uiState.isHaveDownloadableBlocks, - content = { - val textColor = if (uiState.isHaveDownloadableBlocks) { - MaterialTheme.appColors.primaryButtonText - } else { - MaterialTheme.appColors.textPrimaryVariant + if (uiState.progressBarValue != 1f) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXButton( + text = stringResource(R.string.course_download_all), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onDownloadAllClick, + enabled = uiState.isHaveDownloadableBlocks, + content = { + val textColor = if (uiState.isHaveDownloadableBlocks) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.textPrimaryVariant + } + IconText( + text = stringResource(R.string.course_download_all), + icon = Icons.Outlined.CloudDownload, + color = textColor, + textStyle = MaterialTheme.appTypography.labelLarge + ) } - IconText( - text = stringResource(R.string.course_download_all), - icon = Icons.Outlined.CloudDownload, - color = textColor, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) + ) + } } } } @@ -144,6 +160,7 @@ private fun CourseOfflineUI( private fun DownloadProgress( modifier: Modifier = Modifier, uiState: CourseOfflineUIState, + onStopDownloadClick: () -> Unit, ) { Column( modifier = modifier @@ -165,7 +182,9 @@ private fun DownloadProgress( } Spacer(modifier = Modifier.height(4.dp)) Row( - modifier.fillMaxWidth(), + modifier + .fillMaxWidth() + .height(40.dp), horizontalArrangement = Arrangement.SpaceBetween ) { IconText( @@ -174,15 +193,44 @@ private fun DownloadProgress( color = MaterialTheme.appColors.successGreen, textStyle = MaterialTheme.appTypography.labelLarge ) - IconText( - text = stringResource(R.string.course_ready_to_download), - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.textDark, - textStyle = MaterialTheme.appTypography.labelLarge - ) + if (!uiState.isDownloading) { + IconText( + text = stringResource(R.string.course_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } else { + Row { + Box( + modifier.offset(y = (-12).dp, x = 6.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + onClick = onStopDownloadClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } + Text( + text = stringResource(R.string.course_downloading), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelLarge + ) + } + } } if (uiState.progressBarValue != 0f) { - Spacer(modifier = Modifier.height(20.dp)) LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -193,13 +241,13 @@ private fun DownloadProgress( color = MaterialTheme.appColors.successGreen, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) + } else { + Text( + text = stringResource(R.string.course_you_can_download_course_content_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) } - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = stringResource(R.string.course_you_can_download_course_content_offline), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textDark - ) } } @@ -241,9 +289,11 @@ private fun CourseOfflineUIPreview() { isHaveDownloadableBlocks = true, readyToDownloadSize = "159MB", downloadedSize = "0MB", - progressBarValue = 0f + progressBarValue = 0f, + isDownloading = true ), - onDownloadAllClick = {} + onDownloadAllClick = {}, + onStopDownloadClick = {} ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt index 79260561f..a51f1dbb8 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -2,6 +2,7 @@ package org.openedx.course.presentation.offline data class CourseOfflineUIState( val isHaveDownloadableBlocks: Boolean, + val isDownloading: Boolean, val readyToDownloadSize: String, val downloadedSize: String, val progressBarValue: Float diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 032af3d63..67b36611a 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -1,11 +1,13 @@ package org.openedx.course.presentation.offline +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.extension.toFileSize @@ -15,13 +17,19 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager class CourseOfflineViewModel( val courseId: String, val courseTitle: String, val courseInteractor: CourseInteractor, private val preferencesManager: CorePreferences, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + private val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -37,6 +45,7 @@ class CourseOfflineViewModel( private val _uiState = MutableStateFlow( CourseOfflineUIState( isHaveDownloadableBlocks = false, + isDownloading = false, readyToDownloadSize = "", downloadedSize = "", progressBarValue = 0f @@ -46,12 +55,58 @@ class CourseOfflineViewModel( get() = _uiState.asStateFlow() init { + viewModelScope.launch { + downloadModelsStatusFlow.collect { + val isDownloading = it.any { it.value.isWaitingOrDownloading } + _uiState.update { it.copy(isDownloading = isDownloading) } + } + } + getOfflineData() } + fun downloadAllBlocks(fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + setBlocks(courseStructure.blockData) + val downloadModels = courseInteractor.getAllDownloadModels() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + subSectionsBlocks.forEach { + addDownloadableChildrenForSequentialBlock(it) + } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && block.isDownloadable && !downloadModels.any { it.id == block.id } + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + + downloadDialogManager.showPopup( + subSectionsBlocks = notDownloadedSubSectionBlocks, + courseId = courseId, + isAllBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + + fun navigateToDownloadQueue(fragmentManager: FragmentManager) { + val downloadableChildren = + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .mapNotNull { getDownloadableChildren(it.id) } + .flatten() + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } + private fun getOfflineData() { viewModelScope.launch { - val courseStructure = courseInteractor.getCourseStructure(courseId) + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) val downloadableFilesSize = getFilesSize(courseStructure.blockData) if (downloadableFilesSize == 0L) return@launch 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 1da340e20..6279f0ce7 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 @@ -366,12 +366,18 @@ private fun CourseSubsectionItem( } else { stringResource(id = R.string.course_accessibility_download_course_section) } + val downloadIconTint = + if (downloadedState == DownloadedState.DOWNLOADED) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.textAccent + } IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( imageVector = downloadIcon, contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary + tint = downloadIconTint ) } } else if (downloadedState != null) { diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2bb6f3154..2514d4f63 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -85,6 +85,7 @@ Downloaded Ready to Download You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading %1$s of %2$s assignments complete From 03cc05cc39eb4853be75b35157b4abe955d7be5b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Sat, 15 Jun 2024 20:28:34 +0300 Subject: [PATCH 10/23] feat: List of the largest downloads --- .../module/download/BaseDownloadViewModel.kt | 6 + core/src/main/res/values/strings.xml | 1 + .../download/DownloadConfirmDialogFragment.kt | 2 +- .../download/DownloadDialogItem.kt | 5 +- .../download/DownloadDialogManager.kt | 20 +++ .../download/DownloadErrorDialogFragment.kt | 3 +- .../DownloadStorageErrorDialogFragment.kt | 2 +- .../presentation/download/DownloadView.kt | 18 +- .../offline/CourseOfflineScreen.kt | 158 +++++++++++++++++- .../offline/CourseOfflineUIState.kt | 3 + .../offline/CourseOfflineViewModel.kt | 32 ++++ course/src/main/res/values/strings.xml | 1 + 12 files changed, 237 insertions(+), 14 deletions(-) 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 4d90a1b2d..34bc3ec12 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 @@ -194,6 +194,12 @@ abstract class BaseDownloadViewModel( } } + fun removeBlockDownloadModel(blockId: String) { + viewModelScope.launch { + workerController.removeModel(blockId) + } + } + protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { for (item in sequentialBlock.descendants) { allBlocks[item]?.let { blockDescendant -> diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index db4ee003a..c906ae92d 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -191,4 +191,5 @@ More Dates Confirm Download + Edit diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index f9ddb7bd2..9f40d63d4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -168,7 +168,7 @@ private fun DownloadConfirmDialogView( } Column { uiState.downloadDialogItems.forEach { - DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + DownloadDialogItem(downloadDialogItem = it) } } Text( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt index 1f020e698..9f3cfc4d4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt @@ -1,10 +1,13 @@ package org.openedx.course.presentation.download import android.os.Parcelable +import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue @Parcelize data class DownloadDialogItem( val title: String, - val size: Long + val size: Long, + val icon: @RawValue ImageVector? = null ) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index f6f12ef4a..f45b16ab2 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -129,6 +129,26 @@ class DownloadDialogManager( ) } + fun showRemoveDownloadModelPopup( + downloadDialogItem: DownloadDialogItem, + fragmentManager: FragmentManager, + removeDownloadModels: () -> Unit, + ) { + CoroutineScope(Dispatchers.IO).launch { + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = listOf(downloadDialogItem), + isAllBlocksDownloaded = true, + isDownloadFailed = false, + sizeSum = downloadDialogItem.size, + fragmentManager = fragmentManager, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = {} + ) + ) + } + } + fun showDownloadFailedPopup( downloadModel: List, fragmentManager: FragmentManager, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 2e180b99f..0d0c0472c 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.extension.parcelable -import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.ui.OpenEdXButton @@ -152,7 +151,7 @@ private fun DownloadErrorDialogView( } Column { uiState.downloadDialogItems.forEach { - DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + DownloadDialogItem(downloadDialogItem = it) } } Text( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index c6b191beb..a649f556c 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -137,7 +137,7 @@ private fun DownloadStorageErrorDialogView( } Column { uiState.downloadDialogItems.forEach { - DownloadDialogItem(title = it.title, size = it.size.toFileSize(0, false)) + DownloadDialogItem(downloadDialogItem = it) } } StorageBar( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt index 9f64e015e..f322f8376 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -10,20 +10,24 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import org.openedx.core.R +import org.openedx.core.extension.toFileSize import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography @Composable fun DownloadDialogItem( modifier: Modifier = Modifier, - title: String, - size: String, - icon: Painter = painterResource(id = R.drawable.ic_core_chapter_icon) + downloadDialogItem: DownloadDialogItem, ) { + val icon = if (downloadDialogItem.icon != null) { + rememberVectorPainter(downloadDialogItem.icon) + } else { + painterResource(id = R.drawable.ic_core_chapter_icon) + } Row( modifier = modifier.padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -37,14 +41,14 @@ fun DownloadDialogItem( ) Text( modifier = Modifier.weight(1f), - text = title, + text = downloadDialogItem.title, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textDark ) Text( - text = size, + text = downloadDialogItem.size.toFileSize(0, false), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textFieldHint ) } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index e1dcd19ad..30a5e3861 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.offline +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,9 +12,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape 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 @@ -22,25 +25,35 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material.icons.rounded.Delete 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.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager +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.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.WindowSize @@ -51,6 +64,7 @@ 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.core.R as coreR @Composable fun CourseOfflineScreen( @@ -68,6 +82,12 @@ fun CourseOfflineScreen( }, onStopDownloadClick = { viewModel.navigateToDownloadQueue(fragmentManager) + }, + onDeleteClick = { downloadModel -> + viewModel.removeDownloadModel( + downloadModel, + fragmentManager + ) } ) } @@ -78,6 +98,7 @@ private fun CourseOfflineUI( uiState: CourseOfflineUIState, onDownloadAllClick: () -> Unit, onStopDownloadClick: () -> Unit, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -150,12 +171,131 @@ private fun CourseOfflineUI( } ) } + if (uiState.largestDownloads.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + LargestDownloads( + largestDownloads = uiState.largestDownloads, + onDeleteClick = onDeleteClick + ) + } } } } } } +@Composable +private fun LargestDownloads( + largestDownloads: List, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, +) { + var isEditingEnabled by rememberSaveable { + mutableStateOf(false) + } + val text = if (!isEditingEnabled) { + stringResource(coreR.string.core_edit) + } else { + stringResource(coreR.string.core_label_done) + } + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.course_largest_downloads), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Text( + modifier = Modifier.clickable { + isEditingEnabled = !isEditingEnabled + }, + text = text, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textAccent, + ) + } + Spacer(modifier = Modifier.height(20.dp)) + largestDownloads.forEach { + DownloadItem( + downloadModel = it, + isEditingEnabled = isEditingEnabled, + onDeleteClick = onDeleteClick + ) + } + } +} + +@Composable +private fun DownloadItem( + modifier: Modifier = Modifier, + downloadModel: DownloadModel, + isEditingEnabled: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit +) { + val fileIcon = if (downloadModel.type == FileType.VIDEO) { + Icons.Outlined.SmartDisplay + } else { + Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadIcon: ImageVector + val downloadIconTint: Color + val downloadIconClick: Modifier + if (isEditingEnabled) { + downloadIcon = Icons.Rounded.Delete + downloadIconTint = MaterialTheme.appColors.error + downloadIconClick = Modifier.clickable { + onDeleteClick(downloadModel) + } + } else { + downloadIcon = Icons.Default.CloudDone + downloadIconTint = MaterialTheme.appColors.successGreen + downloadIconClick = Modifier + } + + Column { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = fileIcon, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = downloadModel.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = downloadModel.size.toFileSize(0, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Icon( + modifier = Modifier + .size(24.dp) + .then(downloadIconClick), + imageVector = downloadIcon, + tint = downloadIconTint, + contentDescription = null + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + } +} + @Composable private fun DownloadProgress( modifier: Modifier = Modifier, @@ -290,10 +430,24 @@ private fun CourseOfflineUIPreview() { readyToDownloadSize = "159MB", downloadedSize = "0MB", progressBarValue = 0f, - isDownloading = true + isDownloading = true, + largestDownloads = listOf( + DownloadModel( + "", + "", + "", + 0, + "", + "", + FileType.X_BLOCK, + DownloadedState.DOWNLOADED, + null + ) + ), ), onDownloadAllClick = {}, - onStopDownloadClick = {} + onStopDownloadClick = {}, + onDeleteClick = {} ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt index a51f1dbb8..005c663c6 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -1,7 +1,10 @@ package org.openedx.course.presentation.offline +import org.openedx.core.module.db.DownloadModel + data class CourseOfflineUIState( val isHaveDownloadableBlocks: Boolean, + val largestDownloads: List, val isDownloading: Boolean, val readyToDownloadSize: String, val downloadedSize: String, diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 67b36611a..e290f13cb 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -1,5 +1,8 @@ package org.openedx.course.presentation.offline +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.SmartDisplay import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +16,8 @@ import org.openedx.core.domain.model.Block import org.openedx.core.extension.toFileSize 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.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper @@ -20,6 +25,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogItem import org.openedx.course.presentation.download.DownloadDialogManager class CourseOfflineViewModel( @@ -45,6 +51,7 @@ class CourseOfflineViewModel( private val _uiState = MutableStateFlow( CourseOfflineUIState( isHaveDownloadableBlocks = false, + largestDownloads = emptyList(), isDownloading = false, readyToDownloadSize = "", downloadedSize = "", @@ -104,6 +111,26 @@ class CourseOfflineViewModel( courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } + fun removeDownloadModel(downloadModel: DownloadModel, fragmentManager: FragmentManager) { + val icon = if (downloadModel.type == FileType.VIDEO) { + Icons.Outlined.SmartDisplay + } else { + Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadDialogItem = DownloadDialogItem( + title = downloadModel.title, + size = downloadModel.size, + icon = icon + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + super.removeBlockDownloadModel(downloadModel.id) + }, + ) + } + private fun getOfflineData() { viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) @@ -116,10 +143,15 @@ class CourseOfflineViewModel( .map { it.id } val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } val downloadedFilesSize = getFilesSize(downloadedBlocks) + val largestDownloads = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sortedByDescending { it.size } + .take(5) _uiState.update { it.copy( isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(0, false), downloadedSize = downloadedFilesSize.toFileSize(0, false), progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2514d4f63..a93924da0 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ Ready to Download You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. Downloading + Largest Downloads %1$s of %2$s assignments complete From af68d806d9009e3bf71e33b8379f343b88c00547 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Sat, 15 Jun 2024 20:52:53 +0300 Subject: [PATCH 11/23] feat: Remove all downloads --- .../download/DownloadConfirmDialogFragment.kt | 5 +- .../offline/CourseOfflineScreen.kt | 105 +++++++++++------- .../offline/CourseOfflineViewModel.kt | 59 +++++++--- course/src/main/res/values/strings.xml | 3 +- 4 files changed, 118 insertions(+), 54 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 9f40d63d4..6fa84b0bf 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -74,7 +74,10 @@ class DownloadConfirmDialogFragment : DialogFragment() { DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( title = stringResource(id = R.string.course_download_on_cellural), - description = stringResource(id = R.string.course_download_on_cellural_dialog_description), + description = stringResource( + id = R.string.course_download_on_cellural_dialog_description, + sizeSumString + ), icon = painterResource(id = org.openedx.core.R.drawable.core_ic_warning), ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index 30a5e3861..d114e9f0f 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -56,6 +57,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.WindowSize import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize @@ -88,7 +90,10 @@ fun CourseOfflineScreen( downloadModel, fragmentManager ) - } + }, + onDeleteAllClick = { + viewModel.removeDownloadModel(fragmentManager) + }, ) } @@ -99,6 +104,7 @@ private fun CourseOfflineUI( onDownloadAllClick: () -> Unit, onStopDownloadClick: () -> Unit, onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() @@ -135,48 +141,51 @@ private fun CourseOfflineUI( modifier = modifierScreenWidth, color = MaterialTheme.appColors.background, ) { - Column( + LazyColumn( Modifier .fillMaxWidth() - .padding(top = 20.dp) + .padding(top = 20.dp, bottom = 24.dp) .then(horizontalPadding) ) { - if (uiState.isHaveDownloadableBlocks) { - DownloadProgress( - uiState = uiState, - onStopDownloadClick = onStopDownloadClick, - ) - } else { - NoDownloadableBlocksProgress() - } - if (uiState.progressBarValue != 1f) { - Spacer(modifier = Modifier.height(20.dp)) - OpenEdXButton( - text = stringResource(R.string.course_download_all), - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = onDownloadAllClick, - enabled = uiState.isHaveDownloadableBlocks, - content = { - val textColor = if (uiState.isHaveDownloadableBlocks) { - MaterialTheme.appColors.primaryButtonText - } else { - MaterialTheme.appColors.textPrimaryVariant + item { + if (uiState.isHaveDownloadableBlocks) { + DownloadProgress( + uiState = uiState, + onStopDownloadClick = onStopDownloadClick, + ) + } else { + NoDownloadableBlocksProgress() + } + if (uiState.progressBarValue != 1f) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXButton( + text = stringResource(R.string.course_download_all), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onDownloadAllClick, + enabled = uiState.isHaveDownloadableBlocks, + content = { + val textColor = if (uiState.isHaveDownloadableBlocks) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.textPrimaryVariant + } + IconText( + text = stringResource(R.string.course_download_all), + icon = Icons.Outlined.CloudDownload, + color = textColor, + textStyle = MaterialTheme.appTypography.labelLarge + ) } - IconText( - text = stringResource(R.string.course_download_all), - icon = Icons.Outlined.CloudDownload, - color = textColor, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } - if (uiState.largestDownloads.isNotEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) - LargestDownloads( - largestDownloads = uiState.largestDownloads, - onDeleteClick = onDeleteClick - ) + ) + } + if (uiState.largestDownloads.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + LargestDownloads( + largestDownloads = uiState.largestDownloads, + onDeleteClick = onDeleteClick, + onDeleteAllClick = onDeleteAllClick + ) + } } } } @@ -188,6 +197,7 @@ private fun CourseOfflineUI( private fun LargestDownloads( largestDownloads: List, onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit ) { var isEditingEnabled by rememberSaveable { mutableStateOf(false) @@ -222,6 +232,22 @@ private fun LargestDownloads( onDeleteClick = onDeleteClick ) } + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_remove_all_downloads), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onDeleteAllClick, + content = { + IconText( + text = stringResource(R.string.course_remove_all_downloads), + icon = Icons.Rounded.Delete, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) } } @@ -447,7 +473,8 @@ private fun CourseOfflineUIPreview() { ), onDownloadAllClick = {}, onStopDownloadClick = {}, - onDeleteClick = {} + onDeleteClick = {}, + onDeleteAllClick = {} ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index e290f13cb..3f7f396e3 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.icons.outlined.SmartDisplay import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,7 +18,6 @@ import org.openedx.core.extension.toFileSize 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.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper @@ -69,18 +69,17 @@ class CourseOfflineViewModel( } } - getOfflineData() + viewModelScope.launch { + async { initDownloadFragment() }.await() + getOfflineData() + } } fun downloadAllBlocks(fragmentManager: FragmentManager) { viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) - setBlocks(courseStructure.blockData) val downloadModels = courseInteractor.getAllDownloadModels() val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - subSectionsBlocks.forEach { - addDownloadableChildrenForSequentialBlock(it) - } val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } val notDownloadedBlocks = courseStructure.blockData.filter { block -> @@ -131,20 +130,54 @@ class CourseOfflineViewModel( ) } + fun removeDownloadModel(fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val downloadModels = courseInteractor.getAllDownloadModels() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + val downloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && block.isDownloadable && downloadModels.any { it.id == block.id } + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + downloadDialogManager.showPopup( + subSectionsBlocks = downloadedSubSectionBlocks, + courseId = courseId, + isAllBlocksDownloaded = true, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + super.removeDownloadModels(blockId) + } + ) + } + } + + private suspend fun initDownloadFragment() { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + setBlocks(courseStructure.blockData) + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { + addDownloadableChildrenForSequentialBlock(it) + } + + } + private fun getOfflineData() { viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) - val downloadableFilesSize = getFilesSize(courseStructure.blockData) + val downloadableFilesSize = getFilesSize(courseStructure.blockData) * 2 if (downloadableFilesSize == 0L) return@launch - courseInteractor.getDownloadModels().collect { downloadModels -> - val downloadedModelsIds = downloadModels - .filter { it.downloadedState.isDownloaded && it.courseId == courseId } - .map { it.id } + courseInteractor.getDownloadModels().collect { + val downloadModels = it.filter { it.downloadedState.isDownloaded && it.courseId == courseId } + val downloadedModelsIds = downloadModels.map { it.id } val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } - val downloadedFilesSize = getFilesSize(downloadedBlocks) + val downloadedFilesSize = getFilesSize(downloadedBlocks) * 2 val largestDownloads = downloadModels - .filter { it.downloadedState == DownloadedState.DOWNLOADED } .sortedByDescending { it.size } .take(5) diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index a93924da0..c6b3a82f3 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -70,7 +70,7 @@ Unfortunately, this content failed to download. Please try again later or report this issue. Downloading this %1$s of content will save available blocks offline. Download on Cellular? - Downloading this content will use 99MB of cellular data. + Downloading this content will use %1$s of cellular data. Remove Offline Content? Removing this content will free up %1$s. Download @@ -87,6 +87,7 @@ You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. Downloading Largest Downloads + Remove all downloads %1$s of %2$s assignments complete From 6af9d6bc2ec04224a169cd305518401b34683dfb Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Jun 2024 15:38:39 +0300 Subject: [PATCH 12/23] fix: Fixes according to demo feedback --- .../java/org/openedx/app/di/ScreenModule.kt | 6 -- .../org/openedx/core/module/DownloadWorker.kt | 27 ++++--- .../openedx/core/module/TranscriptManager.kt | 2 +- .../module/download/AbstractDownloader.kt | 16 +++- .../download/DownloadConfirmDialogFragment.kt | 2 +- .../download/DownloadDialogManager.kt | 7 +- .../DownloadStorageErrorDialogFragment.kt | 18 +++-- .../presentation/download/DownloadView.kt | 7 +- .../offline/CourseOfflineScreen.kt | 4 +- .../offline/CourseOfflineViewModel.kt | 41 +++++----- .../section/CourseSectionFragment.kt | 76 ------------------- .../section/CourseSectionUIState.kt | 2 - .../section/CourseSectionViewModel.kt | 58 +------------- 13 files changed, 73 insertions(+), 193 deletions(-) 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 ea2d1e116..f505fa303 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -260,12 +260,6 @@ val screenModule = module { get(), get(), get(), - get(), - get(), - get(), - get(), - get(), - get(), ) } viewModel { (courseId: String, unitId: String) -> 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 4103257c9..99297e452 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -19,6 +19,7 @@ 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.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader @@ -130,15 +131,23 @@ class DownloadWorker( ) ) ) - val isSuccess = fileDownloader.download(downloadTask.url, downloadTask.path) - if (isSuccess) { - val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) - downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom(updatedModel) - ) - } else { - downloadDao.removeDownloadModel(downloadTask.id) - downloadError.add(downloadTask) + val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) + when (downloadResult) { + DownloadResult.SUCCESS -> { + val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) + downloadDao.updateDownloadModel( + DownloadModelEntity.createFrom(updatedModel) + ) + } + + DownloadResult.CANCELED -> { + downloadDao.removeDownloadModel(downloadTask.id) + } + + DownloadResult.ERROR -> { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } } newDownload() } else { diff --git a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt index c08870a33..114fc3147 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -65,7 +65,7 @@ class TranscriptManager( downloadLink, file.path ) - if (result) { + if (result == AbstractDownloader.DownloadResult.SUCCESS) { getInputStream(downloadLink)?.let { val transcriptTimedTextObject = convertIntoTimedTextObject(it) diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 40144325e..861de8301 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -35,7 +35,7 @@ abstract class AbstractDownloader : KoinComponent { open suspend fun download( url: String, path: String - ): Boolean { + ): DownloadResult { isCanceled = false return try { val response = downloadApi.downloadFile(url).body() @@ -56,13 +56,17 @@ abstract class AbstractDownloader : KoinComponent { } output?.flush() } - true + DownloadResult.SUCCESS } else { - false + DownloadResult.ERROR } } catch (e: Exception) { e.printStackTrace() - false + if (isCanceled) { + DownloadResult.CANCELED + } else { + DownloadResult.ERROR + } } finally { fos?.close() input?.close() @@ -88,4 +92,8 @@ abstract class AbstractDownloader : KoinComponent { } } + enum class DownloadResult { + SUCCESS, CANCELED, ERROR + } + } \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 6fa84b0bf..decbf3597 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -62,7 +62,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { val dialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme - val sizeSumString = uiState.sizeSum.toFileSize(0, false) + val sizeSumString = uiState.sizeSum.toFileSize(1, false) val dialogData = when (dialogType) { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( title = stringResource(id = coreR.string.course_confirm_download), diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index f45b16ab2..97bf26d5a 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -23,6 +23,7 @@ class DownloadDialogManager( companion object { const val MAX_CELLULAR_SIZE = 100000000 // 100MB + const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size } private val uiState = MutableSharedFlow() @@ -64,7 +65,7 @@ class DownloadDialogManager( ) } - StorageManager.getFreeStorage() < uiState.sizeSum -> { + StorageManager.getFreeStorage() < uiState.sizeSum * DOWNLOAD_SIZE_FACTOR -> { val dialog = DownloadStorageErrorDialogFragment.newInstance( uiState = uiState ) @@ -176,7 +177,7 @@ class DownloadDialogManager( val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } - val size = blocks.sumOf { getFileSize(it) * 2 } + val size = blocks.sumOf { getFileSize(it) } if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionsBlock) if (size > 0) { val downloadDialogItem = DownloadDialogItem( @@ -218,7 +219,7 @@ class DownloadDialogManager( val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } } - val size = blocks.sumOf { getFileSize(it) * 2 } + val size = blocks.sumOf { getFileSize(it) } if (size > 0) DownloadDialogItem(title = subSectionsBlock.displayName, size = size) else null } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index a649f556c..4bbe8590b 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -52,6 +52,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR class DownloadStorageErrorDialogFragment : DialogFragment() { @@ -137,13 +138,13 @@ private fun DownloadStorageErrorDialogView( } Column { uiState.downloadDialogItems.forEach { - DownloadDialogItem(downloadDialogItem = it) + DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) } } StorageBar( freeSpace = StorageManager.getFreeStorage(), totalSpace = StorageManager.getTotalStorage(), - requiredSpace = uiState.sizeSum + requiredSpace = uiState.sizeSum * DOWNLOAD_SIZE_FACTOR ) Text( text = downloadDialogResource.description, @@ -173,8 +174,9 @@ private fun StorageBar( val cornerRadius = 2.dp val boxPadding = 1.dp val usedSpace = totalSpace - freeSpace - val usedPercentage = (totalSpace + requiredSpace - freeSpace) / totalSpace.toFloat() - val reqPercentage = (requiredSpace - freeSpace) / totalSpace.toFloat() + val minSize = 0.1f + val freePercentage = freeSpace / requiredSpace.toFloat() + minSize + val reqPercentage = (requiredSpace - freeSpace) / requiredSpace.toFloat() + minSize val animReqPercentage = remember { Animatable(Float.MIN_VALUE) } LaunchedEffect(Unit) { @@ -206,7 +208,7 @@ private fun StorageBar( ) { Box( modifier = Modifier - .weight(usedPercentage) + .weight(freePercentage) .fillMaxHeight() .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) @@ -228,15 +230,15 @@ private fun StorageBar( Text( text = stringResource( org.openedx.course.R.string.course_used_free_storage, - usedSpace.toFileSize(0, false), - freeSpace.toFileSize(0, false) + usedSpace.toFileSize(1, false), + freeSpace.toFileSize(1, false) ), style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textFieldHint, modifier = Modifier.weight(1f) ) Text( - text = requiredSpace.toFileSize(0, false), + text = requiredSpace.toFileSize(1, false), style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.error, ) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt index f322f8376..722528e9f 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.openedx.core.R import org.openedx.core.extension.toFileSize @@ -43,10 +44,12 @@ fun DownloadDialogItem( modifier = Modifier.weight(1f), text = downloadDialogItem.title, style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2 ) Text( - text = downloadDialogItem.size.toFileSize(0, false), + text = downloadDialogItem.size.toFileSize(1, false), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textFieldHint ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index d114e9f0f..fbbe56f5c 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -92,7 +92,7 @@ fun CourseOfflineScreen( ) }, onDeleteAllClick = { - viewModel.removeDownloadModel(fragmentManager) + viewModel.deleteAll(fragmentManager) }, ) } @@ -301,7 +301,7 @@ private fun DownloadItem( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = downloadModel.size.toFileSize(0, false), + text = downloadModel.size.toFileSize(1, false), style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textDark ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 3f7f396e3..1b8e6597c 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -130,27 +130,22 @@ class CourseOfflineViewModel( ) } - fun removeDownloadModel(fragmentManager: FragmentManager) { + fun deleteAll(fragmentManager: FragmentManager) { viewModelScope.launch { - val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) - val downloadModels = courseInteractor.getAllDownloadModels() - val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - val downloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } - val notDownloadedBlocks = courseStructure.blockData.filter { block -> - block.id in verticalBlocks.flatMap { it.descendants } && block.isDownloadable && downloadModels.any { it.id == block.id } - } - if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null - } - downloadDialogManager.showPopup( - subSectionsBlocks = downloadedSubSectionBlocks, - courseId = courseId, - isAllBlocksDownloaded = true, + val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val downloadDialogItem = DownloadDialogItem( + title = courseTitle, + size = downloadModels.sumOf { it.size }, + icon = Icons.AutoMirrored.Outlined.InsertDriveFile + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, fragmentManager = fragmentManager, - removeDownloadModels = ::removeDownloadModels, - saveDownloadModels = { blockId -> - super.removeDownloadModels(blockId) - } + removeDownloadModels = { + downloadModels.forEach { + super.removeBlockDownloadModel(it.id) + } + }, ) } } @@ -169,14 +164,14 @@ class CourseOfflineViewModel( private fun getOfflineData() { viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) - val downloadableFilesSize = getFilesSize(courseStructure.blockData) * 2 + val downloadableFilesSize = getFilesSize(courseStructure.blockData) if (downloadableFilesSize == 0L) return@launch courseInteractor.getDownloadModels().collect { val downloadModels = it.filter { it.downloadedState.isDownloaded && it.courseId == courseId } val downloadedModelsIds = downloadModels.map { it.id } val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } - val downloadedFilesSize = getFilesSize(downloadedBlocks) * 2 + val downloadedFilesSize = getFilesSize(downloadedBlocks) val largestDownloads = downloadModels .sortedByDescending { it.size } .take(5) @@ -185,8 +180,8 @@ class CourseOfflineViewModel( it.copy( isHaveDownloadableBlocks = true, largestDownloads = largestDownloads, - readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(0, false), - downloadedSize = downloadedFilesSize.toFileSize(0, false), + readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(1, false), + downloadedSize = downloadedFilesSize.toFileSize(1, false), progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() ) } 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 6279f0ce7..2aee3cbc5 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 @@ -17,7 +17,6 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -25,15 +24,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDone -import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -67,7 +60,6 @@ import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.extension.serializable -import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -81,7 +73,6 @@ 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.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow @@ -133,15 +124,6 @@ class CourseSectionFragment : Fragment() { ) } }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id) || viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, it.id - ) - } - } ) LaunchedEffect(rememberSaveable { true }) { @@ -194,7 +176,6 @@ private fun CourseSectionScreen( uiMessage: UIMessage?, onBackClick: () -> Unit, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val scaffoldState = rememberScaffoldState() val title = when (uiState) { @@ -283,11 +264,9 @@ private fun CourseSectionScreen( items(uiState.blocks) { block -> CourseSubsectionItem( block = block, - downloadedState = uiState.downloadedState[block.id], onClick = { onItemClick(it) }, - onDownloadClick = onDownloadClick ) Divider() } @@ -304,9 +283,7 @@ private fun CourseSectionScreen( @Composable private fun CourseSubsectionItem( block: Block, - downloadedState: DownloadedState?, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( @@ -320,8 +297,6 @@ private fun CourseSubsectionItem( stringResource(id = R.string.course_accessibility_section_uncompleted) } - val iconModifier = Modifier.size(24.dp) - Column(Modifier.clickable { onClick(block) }) { Row( Modifier @@ -354,53 +329,6 @@ private fun CourseSubsectionItem( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { - Icons.Default.CloudDone - } else { - Icons.Outlined.CloudDownload - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - val downloadIconTint = - if (downloadedState == DownloadedState.DOWNLOADED) { - MaterialTheme.appColors.successGreen - } else { - MaterialTheme.appColors.textAccent - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - imageVector = downloadIcon, - contentDescription = downloadIconDescription, - tint = downloadIconTint - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(34.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(top = 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 - ) - } - } - } CardArrow( degrees = 0f ) @@ -433,14 +361,12 @@ private fun CourseSectionScreenPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default" ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -459,14 +385,12 @@ private fun CourseSectionScreenTabletPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default", ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt index a8a16681a..1606de1e7 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt @@ -1,12 +1,10 @@ package org.openedx.course.presentation.section import org.openedx.core.domain.model.Block -import org.openedx.core.module.db.DownloadedState sealed class CourseSectionUIState { data class Blocks( val blocks: List, - val downloadedState: Map, val sectionName: String, val courseName: String ) : CourseSectionUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index b13e957f2..7f12a314f 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -5,21 +5,15 @@ 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.BlockType import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage -import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.extension.isInternetError -import org.openedx.core.module.DownloadWorkerController -import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.download.BaseDownloadViewModel -import org.openedx.core.module.download.DownloadHelper -import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode 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.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor @@ -31,22 +25,9 @@ class CourseSectionViewModel( val courseId: String, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, - private val networkConnection: NetworkConnection, - private val preferencesManager: CorePreferences, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, - coreAnalytics: CoreAnalytics, - workerController: DownloadWorkerController, - downloadDao: DownloadDao, - downloadHelper: DownloadHelper, -) : BaseDownloadViewModel( - courseId, - downloadDao, - preferencesManager, - workerController, - coreAnalytics, - downloadHelper, -) { +) : BaseViewModel() { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData @@ -60,24 +41,6 @@ class CourseSectionViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - viewModelScope.launch { - downloadModelsStatusFlow.collect { downloadModels -> - when (val state = uiState.value) { - is CourseSectionUIState.Blocks -> { - val list = (uiState.value as CourseSectionUIState.Blocks).blocks - _uiState.value = CourseSectionUIState.Blocks( - sectionName = state.sectionName, - courseName = state.courseName, - blocks = ArrayList(list), - downloadedState = downloadModels.toMap() - ) - } - - else -> {} - } - } - } - viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseSectionChanged) { @@ -96,14 +59,11 @@ class CourseSectionViewModel( CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData - setBlocks(blocks) val newList = getDescendantBlocks(blocks, blockId) val sequentialBlock = getSequentialBlock(blocks, blockId) - initDownloadModelsStatus() _uiState.value = CourseSectionUIState.Blocks( blocks = ArrayList(newList), - downloadedState = getDownloadModelsStatus(), courseName = courseStructure.name, sectionName = sequentialBlock.displayName ) @@ -119,19 +79,6 @@ class CourseSectionViewModel( } } - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi)) - } - } else { - super.saveDownloadModels(folder, id) - } - } - private fun getDescendantBlocks(blocks: List, id: String): List { val resultList = mutableListOf() if (blocks.isEmpty()) return emptyList() @@ -143,7 +90,6 @@ class CourseSectionViewModel( if (blockDescendant != null) { if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant) - addDownloadableChildrenForVerticalBlock(blockDescendant) } } else continue } From 644ffa8a1611bdb70da3276c1c57619aa13bee36 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 18 Jun 2024 18:11:40 +0300 Subject: [PATCH 13/23] feat: Cancel Course Download button --- .../java/org/openedx/app/di/ScreenModule.kt | 1 - .../offline/CourseOfflineScreen.kt | 108 +++++++++--------- .../offline/CourseOfflineViewModel.kt | 21 ++-- course/src/main/res/values/strings.xml | 1 + 4 files changed, 63 insertions(+), 68 deletions(-) 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 f505fa303..2b8b3423e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -432,7 +432,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index fbbe56f5c..5e2cfdef3 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -9,17 +9,14 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -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.Scaffold @@ -27,10 +24,10 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -82,8 +79,8 @@ fun CourseOfflineScreen( onDownloadAllClick = { viewModel.downloadAllBlocks(fragmentManager) }, - onStopDownloadClick = { - viewModel.navigateToDownloadQueue(fragmentManager) + onCancelDownloadClick = { + viewModel.removeDownloadModel() }, onDeleteClick = { downloadModel -> viewModel.removeDownloadModel( @@ -102,7 +99,7 @@ private fun CourseOfflineUI( windowSize: WindowSize, uiState: CourseOfflineUIState, onDownloadAllClick: () -> Unit, - onStopDownloadClick: () -> Unit, + onCancelDownloadClick: () -> Unit, onDeleteClick: (downloadModel: DownloadModel) -> Unit, onDeleteAllClick: () -> Unit ) { @@ -151,12 +148,11 @@ private fun CourseOfflineUI( if (uiState.isHaveDownloadableBlocks) { DownloadProgress( uiState = uiState, - onStopDownloadClick = onStopDownloadClick, ) } else { NoDownloadableBlocksProgress() } - if (uiState.progressBarValue != 1f) { + if (uiState.progressBarValue != 1f && !uiState.isDownloading) { Spacer(modifier = Modifier.height(20.dp)) OpenEdXButton( text = stringResource(R.string.course_download_all), @@ -177,13 +173,32 @@ private fun CourseOfflineUI( ) } ) + } else if (uiState.isDownloading) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_cancel_course_download), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onCancelDownloadClick, + content = { + IconText( + text = stringResource(R.string.course_cancel_course_download), + icon = Icons.Rounded.Close, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) } if (uiState.largestDownloads.isNotEmpty()) { Spacer(modifier = Modifier.height(20.dp)) LargestDownloads( largestDownloads = uiState.largestDownloads, + isDownloading = uiState.isDownloading, onDeleteClick = onDeleteClick, - onDeleteAllClick = onDeleteAllClick + onDeleteAllClick = onDeleteAllClick, ) } } @@ -196,8 +211,9 @@ private fun CourseOfflineUI( @Composable private fun LargestDownloads( largestDownloads: List, + isDownloading: Boolean, onDeleteClick: (downloadModel: DownloadModel) -> Unit, - onDeleteAllClick: () -> Unit + onDeleteAllClick: () -> Unit, ) { var isEditingEnabled by rememberSaveable { mutableStateOf(false) @@ -232,22 +248,24 @@ private fun LargestDownloads( onDeleteClick = onDeleteClick ) } - OpenEdXOutlinedButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.course_remove_all_downloads), - backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.error, - textColor = MaterialTheme.appColors.error, - onClick = onDeleteAllClick, - content = { - IconText( - text = stringResource(R.string.course_remove_all_downloads), - icon = Icons.Rounded.Delete, - color = MaterialTheme.appColors.error, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) + if (!isDownloading) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_remove_all_downloads), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onDeleteAllClick, + content = { + IconText( + text = stringResource(R.string.course_remove_all_downloads), + icon = Icons.Rounded.Delete, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } } } @@ -326,7 +344,6 @@ private fun DownloadItem( private fun DownloadProgress( modifier: Modifier = Modifier, uiState: CourseOfflineUIState, - onStopDownloadClick: () -> Unit, ) { Column( modifier = modifier @@ -367,33 +384,12 @@ private fun DownloadProgress( textStyle = MaterialTheme.appTypography.labelLarge ) } else { - Row { - Box( - modifier.offset(y = (-12).dp, x = 6.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - IconButton( - onClick = onStopDownloadClick - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error - ) - } - } - Text( - text = stringResource(R.string.course_downloading), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelLarge - ) - } + IconText( + text = stringResource(R.string.course_downloading), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) } } if (uiState.progressBarValue != 0f) { @@ -472,7 +468,7 @@ private fun CourseOfflineUIPreview() { ), ), onDownloadAllClick = {}, - onStopDownloadClick = {}, + onCancelDownloadClick = {}, onDeleteClick = {}, onDeleteAllClick = {} ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 1b8e6597c..66500cd1c 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -24,7 +24,6 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogItem import org.openedx.course.presentation.download.DownloadDialogManager @@ -35,7 +34,6 @@ class CourseOfflineViewModel( private val preferencesManager: CorePreferences, private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, - private val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -101,15 +99,6 @@ class CourseOfflineViewModel( } } - fun navigateToDownloadQueue(fragmentManager: FragmentManager) { - val downloadableChildren = - allBlocks.values - .filter { it.type == BlockType.SEQUENTIAL } - .mapNotNull { getDownloadableChildren(it.id) } - .flatten() - courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) - } - fun removeDownloadModel(downloadModel: DownloadModel, fragmentManager: FragmentManager) { val icon = if (downloadModel.type == FileType.VIDEO) { Icons.Outlined.SmartDisplay @@ -150,6 +139,16 @@ class CourseOfflineViewModel( } } + fun removeDownloadModel() { + viewModelScope.launch { + courseInteractor.getAllDownloadModels() + .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } + .forEach { + removeBlockDownloadModel(it.id) + } + } + } + private suspend fun initDownloadFragment() { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) setBlocks(courseStructure.blockData) diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c6b3a82f3..632c6ed28 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Downloading Largest Downloads Remove all downloads + Cancel Course Download %1$s of %2$s assignments complete From 2e20c9b8bcdc4fac557eb457ba8127febffd7d62 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 19 Jun 2024 17:30:19 +0300 Subject: [PATCH 14/23] feat: Sync offline progress to the LMS --- .../java/org/openedx/app/MainViewModel.kt | 4 +- .../main/java/org/openedx/app/di/AppModule.kt | 3 + .../java/org/openedx/app/di/ScreenModule.kt | 14 ++- .../java/org/openedx/app/room/AppDatabase.kt | 4 +- .../worker/OfflineProgressSyncScheduler.kt | 22 +++++ .../app/worker/OfflineProgressSyncWorker.kt | 85 +++++++++++++++++++ .../org/openedx/core/data/api/CourseApi.kt | 10 +++ .../core/data/model/XBlockProgressBody.kt | 8 ++ .../data/model/room/OfflineXBlockProgress.kt | 49 +++++++++++ .../core/module/DownloadWorkerController.kt | 1 + .../org/openedx/core/module/db/DownloadDao.kt | 15 +++- core/src/main/res/values/strings.xml | 1 + .../data/repository/CourseRepository.kt | 35 ++++++++ .../domain/interactor/CourseInteractor.kt | 11 +++ .../container/CourseUnitContainerAdapter.kt | 2 +- .../unit/html/HtmlUnitFragment.kt | 56 ++++++++++-- .../presentation/unit/html/HtmlUnitUIState.kt | 6 ++ .../unit/html/HtmlUnitViewModel.kt | 45 +++++++++- 18 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt create mode 100644 app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index f3d62c04f..a9a4d24ea 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,17 +10,18 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.openedx.app.worker.OfflineProgressSyncScheduler import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery -import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryNavigator class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -36,6 +37,7 @@ class MainViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) + offlineProgressSyncScheduler.scheduleHourlySync() notifier.notifier .onEach { if (it is NavigationToDiscovery) { 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 8b6ef47fb..88e9a1ec5 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -19,6 +19,7 @@ import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME import org.openedx.app.system.notifier.AppNotifier +import org.openedx.app.worker.OfflineProgressSyncScheduler import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -190,4 +191,6 @@ val appModule = module { factory { FileUtil(get()) } single { DownloadHelper(get(), get()) } + + factory { OfflineProgressSyncScheduler(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 2b8b3423e..01ddca072 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -82,7 +82,7 @@ val screenModule = module { get(), ) } - viewModel { MainViewModel(get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -416,7 +416,17 @@ val screenModule = module { get(), ) } - viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } + viewModel { (blockId: String, courseId: String) -> + HtmlUnitViewModel( + blockId, + courseId, + get(), + get(), + get(), + get(), + get() + ) + } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index be320bae7..dc292e554 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity @@ -22,7 +23,8 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEntity::class, EnrolledCourseEntity::class, CourseStructureEntity::class, - DownloadModelEntity::class + DownloadModelEntity::class, + OfflineXBlockProgress::class, ], version = DATABASE_VERSION, exportSchema = false diff --git a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt b/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt new file mode 100644 index 000000000..7b3fce36d --- /dev/null +++ b/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt @@ -0,0 +1,22 @@ +package org.openedx.app.worker + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class OfflineProgressSyncScheduler(private val context: Context) { + + fun scheduleHourlySync() { + val periodicWorkRequest = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) + .addTag(OfflineProgressSyncWorker.WORKER_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + OfflineProgressSyncWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + periodicWorkRequest + ) + } +} diff --git a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt b/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt new file mode 100644 index 000000000..25d1c6b1d --- /dev/null +++ b/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt @@ -0,0 +1,85 @@ +package org.openedx.app.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class OfflineProgressSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val courseInteractor: CourseInteractor by inject() + private val networkConnection: NetworkConnection by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + tryToSyncProgress() + Result.success() + } catch (e: Exception) { + Firebase.crashlytics.log("$e") + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_offline) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_offline_progress_sync), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncProgress() { + if (networkConnection.isOnline()) { + courseInteractor.submitAllOfflineXBlockProgress() + } + } + + + companion object { + const val WORKER_TAG = "progress_sync_worker_tag" + const val NOTIFICATION_ID = 5678 + const val NOTIFICATION_CHANEL_ID = "progress_sync_channel" + } +} diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 6d30a9044..7d834463c 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -10,6 +10,8 @@ import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST @@ -76,4 +78,12 @@ interface CourseApi { @Query("status") status: String? = null, @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + + @FormUrlEncoded + @POST("/courses/{course_id}/xblock/{block_id}/handler/xmodule_handler/problem_check") + suspend fun submitOfflineXBlockProgress( + @Path("course_id") courseId: String, + @Path("block_id") blockId: String, + @FieldMap(encoded = false) progress: Map + ) } diff --git a/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt new file mode 100644 index 000000000..25251abfc --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt @@ -0,0 +1,8 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class XBlockProgressBody( + @SerializedName("body") + val body: String +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt new file mode 100644 index 000000000..f78ef6524 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt @@ -0,0 +1,49 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.json.JSONObject + +@Entity(tableName = "offline_x_block_progress_table") +data class OfflineXBlockProgress( + @PrimaryKey + @ColumnInfo("id") + val blockId: String, + @ColumnInfo("courseId") + val courseId: String, + @Embedded + val jsonProgress: XBlockProgressData, +) + +data class XBlockProgressData( + @PrimaryKey + @ColumnInfo("url") + val url: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("data") + val data: String +) { + + fun toJson(): String { + val jsonObject = JSONObject() + jsonObject.put("url", url) + jsonObject.put("type", type) + jsonObject.put("data", data) + + return jsonObject.toString() + } + + companion object { + fun parseJson(jsonString: String): XBlockProgressData { + val jsonObject = JSONObject(jsonString) + val url = jsonObject.getString("url") + val type = jsonObject.getString("type") + val data = jsonObject.getString("data") + + return XBlockProgressData(url, type, data) + } + } +} 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 2bcff0b06..e440cfcc5 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -83,6 +83,7 @@ class DownloadWorkerController( if (hasDownloading) fileDownloader.cancelDownloading() downloadDao.removeAllDownloadModels(removeIds) + downloadDao.removeOfflineXBlockProgress(removeIds) updateList() 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 f1f6c7ca4..c799167b2 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 @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao interface DownloadDao { @@ -26,8 +27,20 @@ interface DownloadDao { suspend fun readAllData(): List @Query("SELECT * FROM download_model WHERE id in (:ids)") - fun readAllDataByIds(ids: List) : Flow> + fun readAllDataByIds(ids: List): Flow> @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) + + @Query("SELECT * FROM offline_x_block_progress_table WHERE id=:id") + suspend fun getOfflineXBlockProgress(id: String): OfflineXBlockProgress? + + @Query("SELECT * FROM offline_x_block_progress_table") + suspend fun getAllOfflineXBlockProgress(): List + + @Query("DELETE FROM offline_x_block_progress_table WHERE id in (:ids)") + suspend fun removeOfflineXBlockProgress(ids: List) } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c906ae92d..87c1b0339 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -192,4 +192,5 @@ Dates Confirm Download Edit + Offline Progress Sync diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 63585e34e..6bced5713 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.map import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseStructure @@ -93,4 +95,37 @@ class CourseRepository( suspend fun getAnnouncements(courseId: String) = api.getAnnouncements(courseId).map { it.mapToDomain() } + + suspend fun saveOfflineXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + val offlineXBlockProgress = OfflineXBlockProgress( + blockId = blockId, + courseId = courseId, + jsonProgress = XBlockProgressData.parseJson(jsonProgress) + ) + downloadDao.insertOfflineXBlockProgress(offlineXBlockProgress) + } + + suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + suspend fun submitAllOfflineXBlockProgress() { + val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() + allOfflineXBlockProgress.forEach { + submitOfflineXBlockProgress(it.blockId, it.courseId, it.jsonProgress.data) + } + } + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) { + val jsonProgressData = getXBlockProgress(blockId)?.jsonProgress?.data + submitOfflineXBlockProgress(blockId, courseId, jsonProgressData) + } + + private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { + if (!jsonProgressData.isNullOrEmpty()) { + val progressMap = jsonProgressData + .split("&") + .map { it.split("=") } + .associate { it[0] to it[1] } + api.submitOfflineXBlockProgress(courseId, blockId, progressMap) + downloadDao.removeOfflineXBlockProgress(listOf(blockId)) + } + } } 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 c201afa91..22248d57d 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 @@ -77,4 +77,15 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + + suspend fun saveXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + repository.saveOfflineXBlockProgress(blockId, courseId, jsonProgress) + } + + suspend fun getXBlockProgress(blockId: String) = repository.getXBlockProgress(blockId) + + suspend fun submitAllOfflineXBlockProgress() = repository.submitAllOfflineXBlockProgress() + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = + repository.submitOfflineXBlockProgress(blockId, courseId) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index a36c4381e..a70f15bbe 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -92,7 +92,7 @@ class CourseUnitContainerAdapter( HtmlUnitFragment.newInstance( block.id, block.studentViewUrl, - block.displayName, + viewModel.courseId, offlineUrl, lastModified ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index f7aa529ab..71bec4461 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -12,6 +12,7 @@ import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background @@ -50,6 +51,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl @@ -65,20 +67,21 @@ import org.openedx.core.utils.EmailUtil class HtmlUnitFragment : Fragment() { - private val viewModel by viewModel() - private var blockId: String = "" + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_BLOCK_ID, ""), + requireArguments().getString(ARG_COURSE_ID, "") + ) + } private var blockUrl: String = "" private var offlineUrl: String = "" - private var blockTitle: String = "" private var lastModified: String = "" private var fromDownloadedContent: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - blockId = requireArguments().getString(ARG_BLOCK_ID, "") blockUrl = requireArguments().getString(ARG_BLOCK_URL, "") offlineUrl = requireArguments().getString(ARG_OFFLINE_URL, "") - blockTitle = requireArguments().getString(ARG_BLOCK_TITLE, "") lastModified = requireArguments().getString(ARG_LAST_MODIFIED, "") fromDownloadedContent = lastModified.isNotEmpty() } @@ -112,6 +115,7 @@ class HtmlUnitFragment : Fragment() { } val injectJSList by viewModel.injectJSList.collectAsState() + val uiState by viewModel.uiState.collectAsState() val configuration = LocalConfiguration.current @@ -144,8 +148,10 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { + if (uiState.isLoadingEnabled) { if (hasInternetConnection || fromDownloadedContent) { HTMLContentView( + uiState = uiState, windowSize = windowSize, url = url, cookieManager = viewModel.cookieManager, @@ -161,6 +167,9 @@ class HtmlUnitFragment : Fragment() { onWebPageLoaded = { isLoading = false if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) + }, + saveXBlockProgress = { jsonProgress -> + viewModel.saveXBlockProgress(jsonProgress) } ) } else { @@ -184,6 +193,7 @@ class HtmlUnitFragment : Fragment() { } } } + } } } } @@ -192,14 +202,14 @@ class HtmlUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" + private const val ARG_COURSE_ID = "courseId" private const val ARG_BLOCK_URL = "blockUrl" private const val ARG_OFFLINE_URL = "offlineUrl" - private const val ARG_BLOCK_TITLE = "blockTitle" private const val ARG_LAST_MODIFIED = "lastModified" fun newInstance( blockId: String, blockUrl: String, - blockTitle: String, + courseId: String, offlineUrl: String = "", lastModified: String = "" ): HtmlUnitFragment { @@ -209,7 +219,7 @@ class HtmlUnitFragment : Fragment() { ARG_BLOCK_URL to blockUrl, ARG_OFFLINE_URL to offlineUrl, ARG_LAST_MODIFIED to lastModified, - ARG_BLOCK_TITLE to blockTitle + ARG_COURSE_ID to courseId ) return fragment } @@ -219,6 +229,7 @@ class HtmlUnitFragment : Fragment() { @Composable @SuppressLint("SetJavaScriptEnabled") private fun HTMLContentView( + uiState: HtmlUnitUIState, windowSize: WindowSize, url: String, cookieManager: AppCookieManager, @@ -228,6 +239,7 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, + saveXBlockProgress: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -256,6 +268,17 @@ private fun HTMLContentView( onCompletionSet() } }, "callback") + addJavascriptInterface( + JSBridge( + postMessageCallback = { + coroutineScope.launch { + saveXBlockProgress(it) + setupOfflineProgress(it) + } + } + ), + "AndroidBridge" + ) webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -320,6 +343,8 @@ private fun HTMLContentView( domStorageEnabled = true allowFileAccess = true allowContentAccess = true + useWideViewPort = true + cacheMode = WebSettings.LOAD_NO_CACHE } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -331,8 +356,23 @@ private fun HTMLContentView( update = { webView -> if (!isLoading && injectJSList.isNotEmpty()) { injectJSList.forEach { webView.evaluateJavascript(it, null) } + val jsonProgress = uiState.jsonProgress + if (!jsonProgress.isNullOrEmpty()) { + webView.setupOfflineProgress(jsonProgress) + } } } ) } +private fun WebView.setupOfflineProgress(jsonProgress: String) { + loadUrl("javascript:markProblemCompleted('$jsonProgress');") +} + +class JSBridge(val postMessageCallback: (String) -> Unit) { + @Suppress("unused") + @JavascriptInterface + fun postMessage(str: String) { + postMessageCallback(str) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt new file mode 100644 index 000000000..f407ac1cc --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt @@ -0,0 +1,6 @@ +package org.openedx.course.presentation.unit.html + +data class HtmlUnitUIState( + val jsonProgress: String?, + val isLoadingEnabled: Boolean +) \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 9d52c979b..6bb302eed 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -3,7 +3,9 @@ package org.openedx.course.presentation.unit.html import android.content.res.AssetManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config @@ -12,14 +14,22 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.domain.interactor.CourseInteractor class HtmlUnitViewModel( + private val blockId: String, + private val courseId: String, private val config: Config, private val edxCookieManager: AppCookieManager, private val networkConnection: NetworkConnection, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val courseInteractor: CourseInteractor, ) : BaseViewModel() { + private val _uiState = MutableStateFlow(HtmlUnitUIState(null, false)) + val uiState: StateFlow + get() = _uiState.asStateFlow() + private val _injectJSList = MutableStateFlow>(listOf()) val injectJSList = _injectJSList.asStateFlow() @@ -28,6 +38,11 @@ class HtmlUnitViewModel( val apiHostURL get() = config.getApiHostURL() val cookieManager get() = edxCookieManager + init { + tryToSyncProgress() + getXBlockProgress(blockId) + } + fun setWebPageLoaded(assets: AssetManager) { if (_injectJSList.value.isNotEmpty()) return @@ -46,4 +61,32 @@ class HtmlUnitViewModel( notifier.send(CourseCompletionSet()) } } + + fun saveXBlockProgress(jsonProgress: String) { + viewModelScope.launch { + courseInteractor.saveXBlockProgress(blockId, courseId, jsonProgress) + } + } + + private fun tryToSyncProgress() { + viewModelScope.launch { + try { + if (isOnline) { + courseInteractor.submitOfflineXBlockProgress(blockId, courseId) + } + } catch (e: Exception) { + } finally { + _uiState.update { it.copy(isLoadingEnabled = true) } + } + } + } + + private fun getXBlockProgress(blockId: String) { + viewModelScope.launch { + if (!isOnline) { + val xBlockProgress = courseInteractor.getXBlockProgress(blockId) + _uiState.update { it.copy(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) } + } + } + } } From 4351e9494e9ae49ebdca47b35dafc18a73da3bf2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Jun 2024 14:12:32 +0300 Subject: [PATCH 15/23] fix: Fixes according to QA feedback --- .../java/org/openedx/app/di/ScreenModule.kt | 2 + .../java/org/openedx/core/config/UIConfig.kt | 2 + .../org/openedx/core/extension/LongExt.kt | 6 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 6 +- .../download/DownloadDialogManager.kt | 6 +- .../download/DownloadErrorDialogFragment.kt | 6 +- .../outline/CourseOutlineScreen.kt | 2 +- .../outline/CourseOutlineViewModel.kt | 10 ++- .../course/presentation/ui/CourseVideosUI.kt | 22 ++++--- .../videos/CourseVideoViewModel.kt | 62 +++++++++++++++---- default_config/dev/config.yaml | 1 + default_config/prod/config.yaml | 1 + default_config/stage/config.yaml | 1 + 13 files changed, 95 insertions(+), 32 deletions(-) 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 01ddca072..5acc19dea 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -290,6 +290,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt index 86c5d6b2b..0da0388bd 100644 --- a/core/src/main/java/org/openedx/core/config/UIConfig.kt +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -7,4 +7,6 @@ data class UIConfig( val isCourseDropdownNavigationEnabled: Boolean = false, @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") val isCourseUnitProgressEnabled: Boolean = false, + @SerializedName("COURSE_DOWNLOAD_QUEUE_SCREEN") + val isCourseDownloadQueueEnabled: Boolean = false, ) diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index ed84ef0b3..2cffed100 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -8,9 +8,9 @@ fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { 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()) - ) + if (space) " " else "" + units[digitGroups] + val size = this / 1024.0.pow(digitGroups.toDouble()) + val formatString = if (size % 1 == 0.0) "%.0f" else "%.${round}f" + return String.format(formatString, size) + if (space) " " else "" + units[digitGroups] } catch (e: Exception) { println(e.toString()) } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 6c57df741..f0ffdec27 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -833,6 +833,7 @@ fun AutoSizeText( style: TextStyle, color: Color = Color.Unspecified, maxLines: Int = Int.MAX_VALUE, + minSize: Float = 0f ) { var scaledTextStyle by remember { mutableStateOf(style) } var readyToDraw by remember { mutableStateOf(false) } @@ -849,9 +850,8 @@ fun AutoSizeText( softWrap = false, maxLines = maxLines, onTextLayout = { textLayoutResult -> - if (textLayoutResult.didOverflowWidth) { - scaledTextStyle = - scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) + if (textLayoutResult.didOverflowWidth && scaledTextStyle.fontSize.value > minSize) { + scaledTextStyle = scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) } else { readyToDraw = true } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index 97bf26d5a..e529c0ed0 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -97,7 +97,7 @@ class DownloadDialogManager( ) } - else -> { + uiState.sizeSum >= MAX_CELLULAR_SIZE -> { val dialog = DownloadConfirmDialogFragment.newInstance( dialogType = DownloadConfirmDialogType.CONFIRM, uiState = uiState @@ -107,6 +107,10 @@ class DownloadDialogManager( DownloadConfirmDialogFragment.DIALOG_TAG ) } + + else -> { + uiState.saveDownloadModels() + } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 0d0c0472c..9ed4bdada 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -32,6 +32,7 @@ import androidx.fragment.app.DialogFragment import org.openedx.core.extension.parcelable import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme @@ -143,10 +144,11 @@ private fun DownloadErrorDialogView( ) Spacer(modifier = Modifier.width(4.dp)) } - Text( + AutoSizeText( text = downloadDialogResource.title, style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } Column { diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 720430400..d40ae18b6 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -105,7 +105,7 @@ fun CourseOutlineScreen( } }, onSubSectionClick = { subSectionBlock -> - if (viewModel.isCourseNestedListEnabled) { + if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> viewModel.logUnitDetailViewedEvent( unit.blockId, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index e3ad2103c..204f33ced 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -71,7 +71,7 @@ class CourseOutlineViewModel( coreAnalytics, downloadHelper ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -411,7 +411,13 @@ class CourseOutlineViewModel( if (downloadingBlocks.isNotEmpty()) { val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } - courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + removeBlockDownloadModel(it) + } + } } else { downloadDialogManager.showPopup( subSectionsBlocks = requiredSubSections, 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 index d60ec6091..b1974db65 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -103,16 +103,25 @@ fun CourseVideosScreen( viewModel.switchCourseSections(block.id) }, onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { viewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName + subSectionBlock.blockId, + subSectionBlock.displayName ) - viewModel.courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL ) } }, @@ -120,7 +129,6 @@ fun CourseVideosScreen( viewModel.downloadBlocks( blocksIds = blocksIds, fragmentManager = fragmentManager, - context = context ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> 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 4bc6fd600..12dd16261 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,6 +1,5 @@ package org.openedx.course.presentation.videos -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -33,6 +32,7 @@ import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager class CourseVideoViewModel( val courseId: String, @@ -45,6 +45,8 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, @@ -59,7 +61,7 @@ class CourseVideoViewModel( downloadHelper, ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow @@ -217,18 +219,52 @@ class CourseVideoViewModel( return resultBlocks.toList() } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { - blocksIds.forEach { blockId -> - if (isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - getDownloadableChildren(blockId) ?: arrayListOf() - ) - } else if (isBlockDownloaded(blockId)) { - removeDownloadModels(blockId) + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + removeBlockDownloadModel(it) + } + } } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isAllBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } ) } } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index eee22e36d..19f4943fc 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -84,3 +84,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index eee22e36d..19f4943fc 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -84,3 +84,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index eee22e36d..19f4943fc 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -84,3 +84,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false From 7de4862955f1dfa78f782e8bb8cd29453506849c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Jun 2024 19:01:33 +0300 Subject: [PATCH 16/23] fix: Fixes according to PR feedback --- .../main/java/org/openedx/app/AppActivity.kt | 14 +++- .../main/java/org/openedx/app/AppViewModel.kt | 19 +++-- .../java/org/openedx/app/MainViewModel.kt | 3 - .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 7 +- .../worker/OfflineProgressSyncScheduler.kt | 22 ----- .../test/java/org/openedx/AppViewModelTest.kt | 6 -- .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../org/openedx/core/extension/LongExt.kt | 2 +- .../org/openedx/core/module/DownloadWorker.kt | 11 ++- .../openedx/core/module/db/DownloadModel.kt | 2 +- .../module/download/AbstractDownloader.kt | 4 +- .../module/download/BaseDownloadViewModel.kt | 11 --- .../core/module/download/DownloadHelper.kt | 28 ++++++- core/src/main/res/values/strings.xml | 1 + .../org/openedx/core/ui/theme/Colors.kt | 2 +- .../container/CourseContainerViewModel.kt | 4 + .../download/DownloadConfirmDialogFragment.kt | 10 ++- .../download/DownloadConfirmDialogType.kt | 2 +- .../download/DownloadDialogManager.kt | 39 +++++---- .../download/DownloadDialogUIState.kt | 2 +- .../download/DownloadErrorDialogFragment.kt | 6 +- .../download/DownloadErrorDialogType.kt | 2 +- .../DownloadStorageErrorDialogFragment.kt | 23 +++--- .../presentation/download/DownloadView.kt | 4 +- .../offline/CourseOfflineScreen.kt | 31 ++++--- .../offline/CourseOfflineUIState.kt | 2 +- .../offline/CourseOfflineViewModel.kt | 12 ++- .../outline/CourseOutlineViewModel.kt | 12 ++- .../unit/html/HtmlUnitFragment.kt | 80 +++++++++---------- .../presentation/unit/html/HtmlUnitUIState.kt | 2 +- .../unit/html/HtmlUnitViewModel.kt | 3 + .../videos/CourseVideoViewModel.kt | 7 +- .../worker/OfflineProgressSyncScheduler.kt | 35 ++++++++ .../worker/OfflineProgressSyncWorker.kt | 9 +-- .../outline/CourseOutlineViewModelTest.kt | 2 +- .../videos/CourseVideoViewModelTest.kt | 2 +- 37 files changed, 251 insertions(+), 174 deletions(-) delete mode 100644 app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt create mode 100644 course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt rename {app/src/main/java/org/openedx/app => course/src/main/java/org/openedx/course}/worker/OfflineProgressSyncWorker.kt (90%) diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 4ea7b1884..d31102e80 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -12,9 +12,11 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.window.layout.WindowMetricsCalculator import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding @@ -28,6 +30,7 @@ import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger +import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -49,6 +52,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val whatsNewManager by inject() private val corePreferencesManager by inject() private val profileRouter by inject() + private val downloadDialogManager by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -73,7 +77,6 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.logAppLaunchEvent() setContentView(binding.root) val container = binding.rootLayout - viewModel.fragmentManager = supportFragmentManager container.addView(object : View(this) { override fun onConfigurationChanged(newConfig: Configuration?) { @@ -141,6 +144,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.logoutUser.observe(this) { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + + lifecycleScope.launch { + viewModel.downloadFailedDialog.collect { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = it.downloadModel, + fragmentManager = supportFragmentManager, + ) + } + } } override fun onStart() { diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index fe9f03eca..1aef355f8 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -6,6 +6,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.app.deeplink.DeepLink @@ -19,7 +22,6 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.utils.FileUtil -import org.openedx.course.presentation.download.DownloadDialogManager class AppViewModel( private val config: Config, @@ -31,13 +33,17 @@ class AppViewModel( private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, private val downloadNotifier: DownloadNotifier, - private val downloadDialogManager: DownloadDialogManager, ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() val logoutUser: LiveData get() = _logoutUser + private val _downloadFailedDialog = MutableSharedFlow() + val downloadFailedDialog: SharedFlow + get() = _downloadFailedDialog.asSharedFlow() + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private var logoutHandledAt: Long = 0 @@ -45,8 +51,6 @@ class AppViewModel( val isBranchEnabled get() = config.getBranchConfig().enabled private val canResetAppDirectory get() = preferencesManager.canResetAppDirectory - var fragmentManager: FragmentManager? = null - override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) setUserId() @@ -69,12 +73,7 @@ class AppViewModel( viewModelScope.launch { downloadNotifier.notifier.collect { event -> if (event is DownloadFailed) { - fragmentManager?.let { - downloadDialogManager.showDownloadFailedPopup( - downloadModel = event.downloadModel, - fragmentManager = it, - ) - } + _downloadFailedDialog.emit(event) } } } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index a9a4d24ea..ed80d1d6f 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.openedx.app.worker.OfflineProgressSyncScheduler import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier @@ -21,7 +20,6 @@ class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, - private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -37,7 +35,6 @@ class MainViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - offlineProgressSyncScheduler.scheduleHourlySync() notifier.notifier .onEach { if (it is NavigationToDiscovery) { 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 88e9a1ec5..c4ac539cd 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -19,7 +19,6 @@ import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.worker.OfflineProgressSyncScheduler import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -56,6 +55,7 @@ import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics 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 b93633c5a..0e45866a4 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -80,10 +80,9 @@ val screenModule = module { get(), get(), get(), - get(), ) } - viewModel { MainViewModel(get(), get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -438,7 +437,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } @@ -456,6 +456,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } diff --git a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt b/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt deleted file mode 100644 index 7b3fce36d..000000000 --- a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncScheduler.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.openedx.app.worker - -import android.content.Context -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import java.util.concurrent.TimeUnit - -class OfflineProgressSyncScheduler(private val context: Context) { - - fun scheduleHourlySync() { - val periodicWorkRequest = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) - .addTag(OfflineProgressSyncWorker.WORKER_TAG) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - OfflineProgressSyncWorker.WORKER_TAG, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - periodicWorkRequest - ) - } -} diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 4bdc8e1f8..b060fef36 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -31,7 +31,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.utils.FileUtil -import org.openedx.course.presentation.download.DownloadDialogManager @ExperimentalCoroutinesApi class AppViewModelTest { @@ -48,7 +47,6 @@ class AppViewModelTest { private val analytics = mockk() private val fileUtil = mockk() private val deepLinkRouter = mockk() - private val downloadDialogManager = mockk() private val downloadNotifier = mockk() private val user = User(0, "", "", "") @@ -56,7 +54,6 @@ class AppViewModelTest { @Before fun before() { Dispatchers.setMain(dispatcher) - every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit every { downloadNotifier.notifier } returns flow { } } @@ -82,7 +79,6 @@ class AppViewModelTest { deepLinkRouter, fileUtil, downloadNotifier, - downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -116,7 +112,6 @@ class AppViewModelTest { deepLinkRouter, fileUtil, downloadNotifier, - downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -152,7 +147,6 @@ class AppViewModelTest { deepLinkRouter, fileUtil, downloadNotifier, - downloadDialogManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 7d834463c..9a6826f79 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -84,6 +84,6 @@ interface CourseApi { suspend fun submitOfflineXBlockProgress( @Path("course_id") courseId: String, @Path("block_id") blockId: String, - @FieldMap(encoded = false) progress: Map + @FieldMap progress: Map ) } diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index 2cffed100..6a6d757b5 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -9,7 +9,7 @@ fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() val size = this / 1024.0.pow(digitGroups.toDouble()) - val formatString = if (size % 1 == 0.0) "%.0f" else "%.${round}f" + val formatString = if (size % 1 < 0.05) "%.0f" else "%.${round}f" return String.format(formatString, size) + if (space) " " else "" + units[digitGroups] } catch (e: Exception) { println(e.toString()) 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 99297e452..2186dbfc6 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -135,9 +135,14 @@ class DownloadWorker( when (downloadResult) { DownloadResult.SUCCESS -> { val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) - downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom(updatedModel) - ) + if (updatedModel == null) { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } else { + downloadDao.updateDownloadModel( + DownloadModelEntity.createFrom(updatedModel) + ) + } } DownloadResult.CANCELED -> { diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index 3f2c233fe..da736ba28 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -32,4 +32,4 @@ enum class DownloadedState { enum class FileType { VIDEO, X_BLOCK -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 861de8301..146cc1fc3 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -73,7 +73,6 @@ abstract class AbstractDownloader : KoinComponent { } } - suspend fun cancelDownloading() { isCanceled = true withContext(Dispatchers.IO) { @@ -95,5 +94,4 @@ abstract class AbstractDownloader : KoinComponent { enum class DownloadResult { SUCCESS, CANCELED, ERROR } - -} \ No newline at end of file +} 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 34bc3ec12..40d3f1f41 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 @@ -217,17 +217,6 @@ abstract class BaseDownloadViewModel( } } - protected fun addDownloadableChildrenForVerticalBlock(verticalBlock: Block) { - for (unitBlockId in verticalBlock.descendants) { - val block = allBlocks[unitBlockId] - if (block?.isDownloadable == true) { - val id = verticalBlock.id - val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + block.id - } - } - } - fun logBulkDownloadToggleEvent(toggle: Boolean) { logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt index c3ce97700..7c687f58e 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -71,7 +71,7 @@ class DownloadHelper( } } - suspend fun updateDownloadStatus(downloadModel: DownloadModel): DownloadModel { + suspend fun updateDownloadStatus(downloadModel: DownloadModel): DownloadModel? { return when (downloadModel.type) { FileType.VIDEO -> { downloadModel.copy( @@ -81,13 +81,33 @@ class DownloadHelper( } FileType.X_BLOCK -> { - val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) + val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) ?: return null downloadModel.copy( downloadedState = DownloadedState.DOWNLOADED, - size = File(unzippedFolderPath ?: "").length(), - path = unzippedFolderPath ?: "" + size = calculateDirectorySize(File(unzippedFolderPath)), + path = unzippedFolderPath ) } } } + + private fun calculateDirectorySize(directory: File): Long { + var size: Long = 0 + + if (directory.exists()) { + val files = directory.listFiles() + + if (files != null) { + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } + } + + return size + } } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 87c1b0339..0c5392a83 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -193,4 +193,5 @@ Confirm Download Edit Offline Progress Sync + Close diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index c0c91c975..69f550018 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -8,7 +8,7 @@ val light_secondary = Color(0xFF94D3DD) val light_secondary_variant = Color(0xFF94D3DD) val light_background = Color.White val light_surface = Color(0xFFF7F7F8) -val light_error = Color(0xFFFF3D71) +val light_error = Color(0xFFE8174F) val light_onPrimary = Color.White val light_onSecondary = Color.White val light_onBackground = Color.Black diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 0d4cc60b0..41876dabd 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -229,6 +229,10 @@ class CourseContainerViewModel( updateData() } + CourseContainerTab.OFFLINE -> { + updateData() + } + CourseContainerTab.DATES -> { viewModelScope.launch { courseNotifier.send(RefreshDates) diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index decbf3597..1c220903f 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -37,6 +37,7 @@ import org.openedx.core.extension.parcelable import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -78,7 +79,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { id = R.string.course_download_on_cellural_dialog_description, sizeSumString ), - icon = painterResource(id = org.openedx.core.R.drawable.core_ic_warning), + icon = painterResource(id = coreR.drawable.core_ic_warning), ) DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( @@ -163,10 +164,11 @@ private fun DownloadConfirmDialogView( ) Spacer(modifier = Modifier.width(4.dp)) } - Text( + AutoSizeText( text = downloadDialogResource.title, style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } Column { @@ -214,7 +216,7 @@ private fun DownloadConfirmDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.core.R.string.core_cancel), + text = stringResource(id = coreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt index 4418c322c..9c0833ff3 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt @@ -6,4 +6,4 @@ import kotlinx.parcelize.Parcelize @Parcelize enum class DownloadConfirmDialogType : Parcelable { DOWNLOAD_ON_CELLULAR, CONFIRM, REMOVE -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index e529c0ed0..64a95d2d8 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -22,7 +22,7 @@ class DownloadDialogManager( ) { companion object { - const val MAX_CELLULAR_SIZE = 100000000 // 100MB + const val MAX_CELLULAR_SIZE = 104857600 // 100MB const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size } @@ -86,7 +86,7 @@ class DownloadDialogManager( ) } - !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() && uiState.sizeSum >= MAX_CELLULAR_SIZE -> { + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { val dialog = DownloadConfirmDialogFragment.newInstance( dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, uiState = uiState @@ -119,7 +119,8 @@ class DownloadDialogManager( fun showPopup( subSectionsBlocks: List, courseId: String, - isAllBlocksDownloaded: Boolean, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, @@ -128,7 +129,8 @@ class DownloadDialogManager( subSectionsBlocks = subSectionsBlocks, courseId = courseId, fragmentManager = fragmentManager, - isAllBlocksDownloaded = isAllBlocksDownloaded, + isBlocksDownloaded = isBlocksDownloaded, + onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, saveDownloadModels = saveDownloadModels ) @@ -214,15 +216,24 @@ class DownloadDialogManager( subSectionsBlocks: List, courseId: String, fragmentManager: FragmentManager, - isAllBlocksDownloaded: Boolean, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean, removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { CoroutineScope(Dispatchers.IO).launch { val courseStructure = interactor.getCourseStructure(courseId, false) + val downloadModelIds = interactor.getAllDownloadModels().map { it.id } + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } - val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } } + val blocks = verticalBlocks.flatMap { verticalBlock -> + courseStructure.blockData.filter { + it.id in verticalBlock.descendants && + (isBlocksDownloaded == (it.id in downloadModelIds)) && + (!onlyVideoBlocks || it.type == BlockType.VIDEO) + } + } val size = blocks.sumOf { getFileSize(it) } if (size > 0) DownloadDialogItem(title = subSectionsBlock.displayName, size = size) else null } @@ -230,16 +241,12 @@ class DownloadDialogManager( uiState.emit( DownloadDialogUIState( downloadDialogItems = downloadDialogItems, - isAllBlocksDownloaded = isAllBlocksDownloaded, + isAllBlocksDownloaded = isBlocksDownloaded, isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, - removeDownloadModels = { - subSectionsBlocks.forEach { removeDownloadModels(it.id) } - }, - saveDownloadModels = { - subSectionsBlocks.forEach { saveDownloadModels(it.id) } - } + removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } ) ) } @@ -247,9 +254,9 @@ class DownloadDialogManager( private fun getFileSize(block: Block): Long { - return when (block.type) { - BlockType.VIDEO -> block.downloadModel?.size ?: 0 - BlockType.HTML -> block.offlineDownload?.fileSize ?: 0 + return when { + block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0 + block.isxBlock -> block.offlineDownload?.fileSize ?: 0 else -> 0 } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt index 95703d06d..b58e856bd 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -14,4 +14,4 @@ data class DownloadDialogUIState( val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, val saveDownloadModels: () -> Unit -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 9ed4bdada..05d7e0243 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -121,6 +121,10 @@ private fun DownloadErrorDialogView( onCancelClick: () -> Unit, ) { val scrollState = rememberScrollState() + val dismissButtonText = when (dialogType) { + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) + else -> stringResource(id = coreR.string.core_close) + } DefaultDialogBox( modifier = modifier, onDismissClick = onCancelClick @@ -170,7 +174,7 @@ private fun DownloadErrorDialogView( } OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.core.R.string.core_cancel), + text = dismissButtonText, backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt index 12341c9a7..85f01cf1a 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt @@ -6,4 +6,4 @@ import kotlinx.parcelize.Parcelize @Parcelize enum class DownloadErrorDialogType : Parcelable { NO_CONNECTION, WIFI_REQUIRED, DOWNLOAD_FAILED -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index 4bbe8590b..0059f2bec 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -41,18 +41,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.R import org.openedx.core.extension.parcelable import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.system.StorageManager +import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.R as coreR class DownloadStorageErrorDialogFragment : DialogFragment() { @@ -67,9 +69,9 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { OpenEdXTheme { val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme val downloadDialogResource = DownloadDialogResource( - title = stringResource(id = org.openedx.course.R.string.course_device_storage_full), - description = stringResource(id = org.openedx.course.R.string.course_download_device_storage_full_dialog_description), - icon = painterResource(id = org.openedx.course.R.drawable.course_ic_error), + title = stringResource(id = R.string.course_device_storage_full), + description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), ) DownloadStorageErrorDialogView( @@ -130,10 +132,11 @@ private fun DownloadStorageErrorDialogView( ) Spacer(modifier = Modifier.width(4.dp)) } - Text( + AutoSizeText( text = downloadDialogResource.title, style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } Column { @@ -153,7 +156,7 @@ private fun DownloadStorageErrorDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.core_cancel), + text = stringResource(id = coreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, @@ -229,7 +232,7 @@ private fun StorageBar( ) { Text( text = stringResource( - org.openedx.course.R.string.course_used_free_storage, + R.string.course_used_free_storage, usedSpace.toFileSize(1, false), freeSpace.toFileSize(1, false) ), @@ -254,7 +257,7 @@ private fun DownloadStorageErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = org.openedx.course.R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.course_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( @@ -277,4 +280,4 @@ private fun DownloadStorageErrorDialogViewPreview() { onCancelClick = {} ) } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt index 722528e9f..2a760c772 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -35,10 +35,12 @@ fun DownloadDialogItem( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Top), painter = icon, tint = MaterialTheme.appColors.textDark, contentDescription = null, - modifier = Modifier.size(24.dp) ) Text( modifier = Modifier.weight(1f), diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index 5e2cfdef3..cdad27742 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -76,6 +77,7 @@ fun CourseOfflineScreen( CourseOfflineUI( windowSize = windowSize, uiState = uiState, + hasInternetConnection = viewModel.hasInternetConnection, onDownloadAllClick = { viewModel.downloadAllBlocks(fragmentManager) }, @@ -98,6 +100,7 @@ fun CourseOfflineScreen( private fun CourseOfflineUI( windowSize: WindowSize, uiState: CourseOfflineUIState, + hasInternetConnection: Boolean, onDownloadAllClick: () -> Unit, onCancelDownloadClick: () -> Unit, onDeleteClick: (downloadModel: DownloadModel) -> Unit, @@ -152,7 +155,7 @@ private fun CourseOfflineUI( } else { NoDownloadableBlocksProgress() } - if (uiState.progressBarValue != 1f && !uiState.isDownloading) { + if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { Spacer(modifier = Modifier.height(20.dp)) OpenEdXButton( text = stringResource(R.string.course_download_all), @@ -223,6 +226,13 @@ private fun LargestDownloads( } else { stringResource(coreR.string.core_label_done) } + + LaunchedEffect(isDownloading) { + if (isDownloading) { + isEditingEnabled = false + } + } + Column { Row { Text( @@ -231,14 +241,16 @@ private fun LargestDownloads( style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) - Text( - modifier = Modifier.clickable { - isEditingEnabled = !isEditingEnabled - }, - text = text, - style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textAccent, - ) + if (!isDownloading) { + Text( + modifier = Modifier.clickable { + isEditingEnabled = !isEditingEnabled + }, + text = text, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textAccent, + ) + } } Spacer(modifier = Modifier.height(20.dp)) largestDownloads.forEach { @@ -447,6 +459,7 @@ private fun CourseOfflineUIPreview() { OpenEdXTheme { CourseOfflineUI( windowSize = rememberWindowSize(), + hasInternetConnection = true, uiState = CourseOfflineUIState( isHaveDownloadableBlocks = true, readyToDownloadSize = "159MB", diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt index 005c663c6..8abde204f 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -8,5 +8,5 @@ data class CourseOfflineUIState( val isDownloading: Boolean, val readyToDownloadSize: String, val downloadedSize: String, - val progressBarValue: Float + val progressBarValue: Float, ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 66500cd1c..230b30deb 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -22,6 +22,7 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.download.DownloadDialogItem @@ -34,6 +35,7 @@ class CourseOfflineViewModel( private val preferencesManager: CorePreferences, private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, + private val networkConnection: NetworkConnection, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -53,12 +55,15 @@ class CourseOfflineViewModel( isDownloading = false, readyToDownloadSize = "", downloadedSize = "", - progressBarValue = 0f + progressBarValue = 0f, ) ) val uiState: StateFlow get() = _uiState.asStateFlow() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + init { viewModelScope.launch { downloadModelsStatusFlow.collect { @@ -89,7 +94,7 @@ class CourseOfflineViewModel( downloadDialogManager.showPopup( subSectionsBlocks = notDownloadedSubSectionBlocks, courseId = courseId, - isAllBlocksDownloaded = false, + isBlocksDownloaded = false, fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> @@ -171,6 +176,7 @@ class CourseOfflineViewModel( val downloadedModelsIds = downloadModels.map { it.id } val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } val downloadedFilesSize = getFilesSize(downloadedBlocks) + val realDownloadedFilesSize = downloadModels.sumOf { it.size } val largestDownloads = downloadModels .sortedByDescending { it.size } .take(5) @@ -180,7 +186,7 @@ class CourseOfflineViewModel( isHaveDownloadableBlocks = true, largestDownloads = largestDownloads, readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(1, false), - downloadedSize = downloadedFilesSize.toFileSize(1, false), + downloadedSize = realDownloadedFilesSize.toFileSize(1, false), progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() ) } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 204f33ced..193b5c7e9 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -402,7 +402,11 @@ class CourseOutlineViewModel( val notDownloadedBlocks = allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) } - if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + if (notDownloadedBlocks.isNotEmpty()) { + subSectionsBlock + } else { + null + } } val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { @@ -415,14 +419,16 @@ class CourseOutlineViewModel( courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { downloadableChildren.forEach { - removeBlockDownloadModel(it) + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } } } } else { downloadDialogManager.showPopup( subSectionsBlocks = requiredSubSections, courseId = courseId, - isAllBlocksDownloaded = isAllBlocksDownloaded, + isBlocksDownloaded = isAllBlocksDownloaded, fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 71bec4461..eda5ab411 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -149,51 +149,51 @@ class HtmlUnitFragment : Fragment() { contentAlignment = Alignment.TopCenter ) { if (uiState.isLoadingEnabled) { - if (hasInternetConnection || fromDownloadedContent) { - HTMLContentView( - uiState = uiState, - windowSize = windowSize, - url = url, - cookieManager = viewModel.cookieManager, - apiHostURL = viewModel.apiHostURL, - isLoading = isLoading, - injectJSList = injectJSList, - onCompletionSet = { - viewModel.notifyCompletionSet() - }, - onWebPageLoading = { - isLoading = true - }, - onWebPageLoaded = { - isLoading = false - if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) - }, - saveXBlockProgress = { jsonProgress -> - viewModel.saveXBlockProgress(jsonProgress) + if (hasInternetConnection || fromDownloadedContent) { + HTMLContentView( + uiState = uiState, + windowSize = windowSize, + url = url, + cookieManager = viewModel.cookieManager, + apiHostURL = viewModel.apiHostURL, + isLoading = isLoading, + injectJSList = injectJSList, + onCompletionSet = { + viewModel.notifyCompletionSet() + }, + onWebPageLoading = { + isLoading = true + }, + onWebPageLoaded = { + isLoading = false + if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) + }, + saveXBlockProgress = { jsonProgress -> + viewModel.saveXBlockProgress(jsonProgress) + } + ) + } else { + ConnectionErrorView( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.appColors.background) + ) { + hasInternetConnection = viewModel.isOnline } - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - hasInternetConnection = viewModel.isOnline } - } - if (isLoading && hasInternetConnection) { - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) + if (isLoading && hasInternetConnection) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } } } } - } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt index f407ac1cc..2dc14424c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt @@ -3,4 +3,4 @@ package org.openedx.course.presentation.unit.html data class HtmlUnitUIState( val jsonProgress: String?, val isLoadingEnabled: Boolean -) \ No newline at end of file +) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 6bb302eed..24aefd504 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -15,6 +15,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.worker.OfflineProgressSyncScheduler class HtmlUnitViewModel( private val blockId: String, @@ -24,6 +25,7 @@ class HtmlUnitViewModel( private val networkConnection: NetworkConnection, private val notifier: CourseNotifier, private val courseInteractor: CourseInteractor, + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler ) : BaseViewModel() { private val _uiState = MutableStateFlow(HtmlUnitUIState(null, false)) @@ -65,6 +67,7 @@ class HtmlUnitViewModel( fun saveXBlockProgress(jsonProgress: String) { viewModelScope.launch { courseInteractor.saveXBlockProgress(blockId, courseId, jsonProgress) + offlineProgressSyncScheduler.scheduleSync() } } 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 12dd16261..eb2c2d155 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 @@ -252,14 +252,17 @@ class CourseVideoViewModel( courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { downloadableChildren.forEach { - removeBlockDownloadModel(it) + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } } } } else { downloadDialogManager.showPopup( subSectionsBlocks = requiredSubSections, courseId = courseId, - isAllBlocksDownloaded = isAllBlocksDownloaded, + isBlocksDownloaded = isAllBlocksDownloaded, + onlyVideoBlocks = true, fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt new file mode 100644 index 000000000..667772d33 --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt @@ -0,0 +1,35 @@ +package org.openedx.course.worker + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class OfflineProgressSyncScheduler(private val context: Context) { + + fun scheduleSync() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + + val workRequest = OneTimeWorkRequestBuilder() + .addTag(OfflineProgressSyncWorker.WORKER_TAG) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 1, + TimeUnit.HOURS + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + OfflineProgressSyncWorker.WORKER_TAG, + ExistingWorkPolicy.REPLACE, + workRequest + ) + } +} diff --git a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt similarity index 90% rename from app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt rename to course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt index 25d1c6b1d..aa6011741 100644 --- a/app/src/main/java/org/openedx/app/worker/OfflineProgressSyncWorker.kt +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -1,4 +1,4 @@ -package org.openedx.app.worker +package org.openedx.course.worker import android.app.NotificationChannel import android.app.NotificationManager @@ -15,7 +15,6 @@ import com.google.firebase.ktx.Firebase import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.openedx.core.R -import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.domain.interactor.CourseInteractor class OfflineProgressSyncWorker( @@ -24,7 +23,6 @@ class OfflineProgressSyncWorker( ) : CoroutineWorker(context, workerParams), KoinComponent { private val courseInteractor: CourseInteractor by inject() - private val networkConnection: NetworkConnection by inject() private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) @@ -71,12 +69,9 @@ class OfflineProgressSyncWorker( } private suspend fun tryToSyncProgress() { - if (networkConnection.isOnline()) { - courseInteractor.submitAllOfflineXBlockProgress() - } + courseInteractor.submitAllOfflineXBlockProgress() } - companion object { const val WORKER_TAG = "progress_sync_worker_tag" const val NOTIFICATION_ID = 5678 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 1baf0d76e..15901d1b3 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 @@ -248,7 +248,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any()) } returns Unit + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( 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 e057b447a..b8a4d543c 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 @@ -198,7 +198,7 @@ class CourseVideoViewModelTest { Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any()) } returns Unit + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit } @After From 27069f7802fc4cdad150f1024ece93062e255534 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 24 Jun 2024 13:18:10 +0300 Subject: [PATCH 17/23] feat: NoAvailableUnitFragment --- .../course/presentation/ui/CourseVideosUI.kt | 4 +- ...ragment.kt => NotAvailableUnitFragment.kt} | 113 ++++++++++---- .../presentation/unit/NotAvailableUnitType.kt | 9 ++ .../container/CourseUnitContainerAdapter.kt | 139 +++++++++--------- course/src/main/res/values-uk/strings.xml | 2 +- course/src/main/res/values/strings.xml | 8 +- 6 files changed, 174 insertions(+), 101 deletions(-) rename course/src/main/java/org/openedx/course/presentation/unit/{NotSupportedUnitFragment.kt => NotAvailableUnitFragment.kt} (52%) create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt 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 index b1974db65..64022f498 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -109,7 +109,7 @@ fun CourseVideosScreen( fragmentManager, courseId = viewModel.courseId, unitId = unit.id, - mode = CourseViewMode.FULL + mode = CourseViewMode.VIDEOS ) } } else { @@ -121,7 +121,7 @@ fun CourseVideosScreen( fm = fragmentManager, courseId = viewModel.courseId, subSectionId = subSectionBlock.id, - mode = CourseViewMode.FULL + mode = CourseViewMode.VIDEOS ) } }, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt similarity index 52% rename from course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt rename to course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index 0aaae4a3c..b29a7ac8f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -3,10 +3,25 @@ package org.openedx.course.presentation.unit import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +37,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.openedx.core.extension.parcelable import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -31,7 +47,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.course.R as courseR -class NotSupportedUnitFragment : Fragment() { +class NotAvailableUnitFragment : Fragment() { private var blockId: String? = null @@ -49,9 +65,40 @@ class NotSupportedUnitFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - NotSupportedUnitScreen( + val uriHandler = LocalUriHandler.current + val uri = requireArguments().getString(ARG_BLOCK_URL, "") + val title: String + val description: String + var buttonAction: (() -> Unit)? = null + when (requireArguments().parcelable(ARG_UNIT_TYPE)) { + NotAvailableUnitType.MOBILE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_this_interactive_component) + description = stringResource(id = courseR.string.course_explore_other_parts_on_web) + buttonAction = { + uriHandler.openUri(uri) + } + } + + NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_not_available_offline) + description = stringResource(id = courseR.string.course_explore_other_parts_when_reconnect) + } + + NotAvailableUnitType.NOT_DOWNLOADED -> { + title = stringResource(id = courseR.string.course_not_downloaded) + description = + stringResource(id = courseR.string.course_explore_other_parts_when_reconnect_or_download) + } + + else -> { + return@OpenEdXTheme + } + } + NotAvailableUnitScreen( windowSize = windowSize, - uri = requireArguments().getString(ARG_BLOCK_URL, "") + title = title, + description = description, + buttonAction = buttonAction ) } } @@ -60,14 +107,17 @@ class NotSupportedUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_UNIT_TYPE = "notAvailableUnitType" fun newInstance( blockId: String, - blockUrl: String - ): NotSupportedUnitFragment { - val fragment = NotSupportedUnitFragment() + blockUrl: String, + unitType: NotAvailableUnitType, + ): NotAvailableUnitFragment { + val fragment = NotAvailableUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_UNIT_TYPE to unitType ) return fragment } @@ -76,12 +126,13 @@ class NotSupportedUnitFragment : Fragment() { } @Composable -private fun NotSupportedUnitScreen( +private fun NotAvailableUnitScreen( windowSize: WindowSize, - uri: String + title: String, + description: String, + buttonAction: (() -> Unit)? = null, ) { val scaffoldState = rememberScaffoldState() - val uriHandler = LocalUriHandler.current val scrollState = rememberScrollState() Scaffold( modifier = Modifier.fillMaxSize(), @@ -120,7 +171,7 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(36.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_this_interactive_component), + text = title, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center @@ -128,29 +179,31 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(12.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_explore_other_parts), + text = description, style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center ) Spacer(Modifier.height(40.dp)) - Button(modifier = Modifier - .width(216.dp) - .height(42.dp), - shape = MaterialTheme.appShapes.buttonShape, - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground - ), - onClick = { - uriHandler.openUri(uri) - }) { - Text( - text = stringResource(id = courseR.string.course_open_in_browser), - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.labelLarge - ) + if (buttonAction != null) { + Button( + modifier = Modifier + .width(216.dp) + .height(42.dp), + shape = MaterialTheme.appShapes.buttonShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + onClick = buttonAction + ) { + Text( + text = stringResource(id = courseR.string.course_open_in_browser), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(Modifier.height(20.dp)) } - Spacer(Modifier.height(20.dp)) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt new file mode 100644 index 000000000..0b02b876e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.unit + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NotAvailableUnitType : Parcelable { + MOBILE_UNSUPPORTED, OFFLINE_UNSUPPORTED, NOT_DOWNLOADED +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index a70f15bbe..e3143f46f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -4,7 +4,8 @@ import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import org.openedx.core.FragmentViewType import org.openedx.core.domain.model.Block -import org.openedx.course.presentation.unit.NotSupportedUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitType import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment @@ -23,73 +24,40 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) private fun unitBlockFragment(block: Block): Fragment { - return when { - (block.isVideoBlock && - (block.studentViewData?.encodedVideos?.hasVideoUrl == true || - block.studentViewData?.encodedVideos?.hasYoutubeUrl == true)) -> { - val encodedVideos = block.studentViewData?.encodedVideos!! - val transcripts = block.studentViewData!!.transcripts - with(encodedVideos) { - var isDownloaded = false - val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { - isDownloaded = true - viewModel.getDownloadModelById(block.id)!!.path - } else videoUrl - if (videoUrl.isNotEmpty()) { - VideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - videoUrl, - transcripts?.toMap() ?: emptyMap(), - block.displayName, - isDownloaded - ) - } else { - YoutubeVideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - encodedVideos.youtube?.url ?: "", - transcripts?.toMap() ?: emptyMap(), - block.displayName - ) - } - } + val downloadedModel = viewModel.getDownloadModelById(block.id) + val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" + val noNetwork = !viewModel.hasNetworkConnection + + if (noNetwork) { + if (block.isDownloadable && offlineUrl.isEmpty()) { + return createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) + } + if (!block.isDownloadable) { + return createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } + } - (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { - DiscussionThreadsFragment.newInstance( - DiscussionTopicsViewModel.TOPIC, - viewModel.courseId, - block.studentViewData?.topicId ?: "", - block.displayName, - FragmentViewType.MAIN_CONTENT.name, - block.id - ) + when { + block.isVideoBlock && block.studentViewData?.encodedVideos?.run { hasVideoUrl || hasYoutubeUrl } == true -> { + return createVideoFragment(block) } - block.studentViewMultiDevice.not() -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + block.isDiscussionBlock && !block.studentViewData?.topicId.isNullOrEmpty() -> { + return createDiscussionFragment(block) } - block.isHTMLBlock || - block.isProblemBlock || - block.isOpenAssessmentBlock || - block.isDragAndDropBlock || - block.isWordCloudBlock || - block.isLTIConsumerBlock || - block.isSurveyBlock -> { - val downloadedModel = viewModel.getDownloadModelById(block.id) - val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" - val lastModified: String = - if (downloadedModel != null && !viewModel.hasNetworkConnection) { - downloadedModel.lastModified ?: "" - } else { - "" - } - HtmlUnitFragment.newInstance( + !block.studentViewMultiDevice -> { + return createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + } + + block.isHTMLBlock || block.isProblemBlock || block.isOpenAssessmentBlock || block.isDragAndDropBlock || + block.isWordCloudBlock || block.isLTIConsumerBlock || block.isSurveyBlock -> { + val lastModified = if (downloadedModel != null && noNetwork) { + downloadedModel.lastModified ?: "" + } else { + "" + } + return HtmlUnitFragment.newInstance( block.id, block.studentViewUrl, viewModel.courseId, @@ -99,11 +67,50 @@ class CourseUnitContainerAdapter( } else -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + return createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) } } } + + private fun createNotAvailableUnitFragment(block: Block, type: NotAvailableUnitType): Fragment { + return NotAvailableUnitFragment.newInstance(block.id, block.lmsWebUrl, type) + } + + private fun createVideoFragment(block: Block): Fragment { + val encodedVideos = block.studentViewData!!.encodedVideos!! + val transcripts = block.studentViewData!!.transcripts ?: emptyMap() + val downloadedModel = viewModel.getDownloadModelById(block.id) + val isDownloaded = downloadedModel != null + val videoUrl = downloadedModel?.path ?: encodedVideos.videoUrl + + return if (videoUrl.isNotEmpty()) { + VideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + videoUrl, + transcripts, + block.displayName, + isDownloaded + ) + } else { + YoutubeVideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + encodedVideos.youtube?.url ?: "", + transcripts, + block.displayName + ) + } + } + + private fun createDiscussionFragment(block: Block): Fragment { + return DiscussionThreadsFragment.newInstance( + DiscussionTopicsViewModel.TOPIC, + viewModel.courseId, + block.studentViewData?.topicId ?: "", + block.displayName, + FragmentViewType.MAIN_CONTENT.name, + block.id + ) + } } diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 14f3487c4..ae2898cca 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -32,7 +32,7 @@ Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна - Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. + Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. Відкрити в браузері Субтитри Остання активність: diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 632c6ed28..5141911c7 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -27,8 +27,8 @@ This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. You can download content only from Wi-fi - This interactive component isn\'t available on mobile. - Explore other parts of this course or view this on web. + This interactive component isn’t yet available + Explore other parts of this course or view this on web. Open in browser Subtitles Continue with: @@ -89,6 +89,10 @@ Largest Downloads Remove all downloads Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. %1$s of %2$s assignments complete From a8c9e2794ad8937ab4db6d6b5fdf0bb4fb861144 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 24 Jun 2024 20:03:35 +0300 Subject: [PATCH 18/23] fix: Fixes according to QA feedback --- .../org/openedx/core/data/api/CourseApi.kt | 9 ++++---- .../org/openedx/core/extension/LongExt.kt | 2 +- .../data/repository/CourseRepository.kt | 16 +++++++++---- .../container/CourseUnitContainerAdapter.kt | 23 +++++++++---------- .../unit/html/HtmlUnitFragment.kt | 2 +- .../unit/html/HtmlUnitViewModel.kt | 6 +++-- .../worker/OfflineProgressSyncWorker.kt | 2 ++ 7 files changed, 35 insertions(+), 25 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 9a6826f79..1d3c507b8 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -1,5 +1,6 @@ package org.openedx.core.data.api +import okhttp3.MultipartBody import org.openedx.core.data.model.AnnouncementModel import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus @@ -10,11 +11,11 @@ import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body -import retrofit2.http.FieldMap -import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -79,11 +80,11 @@ interface CourseApi { @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments - @FormUrlEncoded + @Multipart @POST("/courses/{course_id}/xblock/{block_id}/handler/xmodule_handler/problem_check") suspend fun submitOfflineXBlockProgress( @Path("course_id") courseId: String, @Path("block_id") blockId: String, - @FieldMap progress: Map + @Part progress: List ) } diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index 6a6d757b5..c3b07f69b 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -9,7 +9,7 @@ fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() val size = this / 1024.0.pow(digitGroups.toDouble()) - val formatString = if (size % 1 < 0.05) "%.0f" else "%.${round}f" + val formatString = if (size % 1 < 0.05 || size % 1 >= 0.95) "%.0f" else "%.${round}f" return String.format(formatString, size) + if (space) " " else "" + units[digitGroups] } catch (e: Exception) { println(e.toString()) diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 6bced5713..4ad5fc402 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,6 +1,7 @@ package org.openedx.course.data.repository import kotlinx.coroutines.flow.map +import okhttp3.MultipartBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody @@ -13,6 +14,8 @@ import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.data.storage.CourseDao +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class CourseRepository( private val api: CourseApi, @@ -120,11 +123,14 @@ class CourseRepository( private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { if (!jsonProgressData.isNullOrEmpty()) { - val progressMap = jsonProgressData - .split("&") - .map { it.split("=") } - .associate { it[0] to it[1] } - api.submitOfflineXBlockProgress(courseId, blockId, progressMap) + val parts = mutableListOf() + val decodedQuery = URLDecoder.decode(jsonProgressData, StandardCharsets.UTF_8.name()) + val keyValuePairs = decodedQuery.split("&") + for (pair in keyValuePairs) { + val (key, value) = pair.split("=") + parts.add(MultipartBody.Part.createFormData(key, value)) + } + api.submitOfflineXBlockProgress(courseId, blockId, parts) downloadDao.removeOfflineXBlockProgress(listOf(blockId)) } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index e3143f46f..a8953baf1 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -28,26 +28,25 @@ class CourseUnitContainerAdapter( val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" val noNetwork = !viewModel.hasNetworkConnection - if (noNetwork) { - if (block.isDownloadable && offlineUrl.isEmpty()) { - return createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) + return when { + noNetwork && block.isDownloadable && offlineUrl.isEmpty() -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } - if (!block.isDownloadable) { - return createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) + + noNetwork && !block.isDownloadable -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } - } - when { block.isVideoBlock && block.studentViewData?.encodedVideos?.run { hasVideoUrl || hasYoutubeUrl } == true -> { - return createVideoFragment(block) + createVideoFragment(block) } block.isDiscussionBlock && !block.studentViewData?.topicId.isNullOrEmpty() -> { - return createDiscussionFragment(block) + createDiscussionFragment(block) } !block.studentViewMultiDevice -> { - return createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) } block.isHTMLBlock || block.isProblemBlock || block.isOpenAssessmentBlock || block.isDragAndDropBlock || @@ -57,7 +56,7 @@ class CourseUnitContainerAdapter( } else { "" } - return HtmlUnitFragment.newInstance( + HtmlUnitFragment.newInstance( block.id, block.studentViewUrl, viewModel.courseId, @@ -67,7 +66,7 @@ class CourseUnitContainerAdapter( } else -> { - return createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index eda5ab411..db88ae6c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -170,7 +170,7 @@ class HtmlUnitFragment : Fragment() { }, saveXBlockProgress = { jsonProgress -> viewModel.saveXBlockProgress(jsonProgress) - } + }, ) } else { ConnectionErrorView( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 24aefd504..f852c1f2d 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -2,6 +2,7 @@ package org.openedx.course.presentation.unit.html import android.content.res.AssetManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,7 +43,6 @@ class HtmlUnitViewModel( init { tryToSyncProgress() - getXBlockProgress(blockId) } fun setWebPageLoaded(assets: AssetManager) { @@ -56,6 +56,7 @@ class HtmlUnitViewModel( assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } _injectJSList.value = jsList + getXBlockProgress() } fun notifyCompletionSet() { @@ -84,10 +85,11 @@ class HtmlUnitViewModel( } } - private fun getXBlockProgress(blockId: String) { + private fun getXBlockProgress() { viewModelScope.launch { if (!isOnline) { val xBlockProgress = courseInteractor.getXBlockProgress(blockId) + delay(500) _uiState.update { it.copy(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) } } } diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt index aa6011741..d41e9909e 100644 --- a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker @@ -33,6 +34,7 @@ class OfflineProgressSyncWorker( tryToSyncProgress() Result.success() } catch (e: Exception) { + Log.e(WORKER_TAG, "$e") Firebase.crashlytics.log("$e") Result.failure() } From 643ade43cf8612935259abe847e0d3fce75a4640 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 17 Jul 2024 19:07:08 +0300 Subject: [PATCH 19/23] fix: Fixes according to QA feedback --- core/src/main/java/org/openedx/core/extension/LongExt.kt | 2 +- core/src/main/java/org/openedx/core/extension/StringExt.kt | 7 +++++++ .../presentation/container/CourseContainerViewModel.kt | 3 ++- .../openedx/courses/presentation/AllEnrolledCoursesView.kt | 3 ++- .../openedx/courses/presentation/DashboardGalleryView.kt | 3 ++- .../dashboard/presentation/DashboardListFragment.kt | 4 ++-- .../org/openedx/discovery/presentation/ui/DiscoveryUI.kt | 4 ++-- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index c3b07f69b..2071b6946 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -5,7 +5,7 @@ import kotlin.math.pow fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { try { - if (this <= 0) return "0" + if (this <= 0) return "0MB" val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() val size = this / 1024.0.pow(digitGroups.toDouble()) diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 343398782..6d8457fed 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -37,3 +37,10 @@ fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault( fun String.takeIfNotEmpty(): String? { return if (this.isEmpty().not()) this else null } + +fun String.toImageLink(apiHostURL: String): String = + if (this.isLinkValid()) { + this + } else { + apiHostURL + this.removePrefix("/") + } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index bc0176e2e..8a586329d 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -25,6 +25,7 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError +import org.openedx.core.extension.toImageLink import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager @@ -203,7 +204,7 @@ class CourseContainerViewModel( private fun loadCourseImage(imageUrl: String?) { imageProcessor.loadImage( - imageUrl = config.getApiHostURL() + imageUrl, + imageUrl = imageUrl?.toImageLink(config.getApiHostURL()) ?: "", defaultImage = CoreR.drawable.core_no_image_course, onComplete = { drawable -> val bitmap = (drawable as BitmapDrawable).bitmap.apply { diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 3392ed7bd..da01992b7 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -81,6 +81,7 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.toImageLink import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -418,7 +419,7 @@ fun CourseItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(apiHostUrl + course.course.courseImage) + .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(R.drawable.core_no_image_course) .placeholder(R.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 7401f6304..ca7b48cc4 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -83,6 +83,7 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Pagination import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.toImageLink import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton @@ -420,7 +421,7 @@ private fun CourseListItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(apiHostUrl + course.course.courseImage) + .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 2d8e81d6b..579076b96 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -81,6 +81,7 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.toImageLink import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -373,7 +374,6 @@ private fun CourseItem( ) ) } - val imageUrl = apiHostUrl + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier @@ -392,7 +392,7 @@ private fun CourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(enrolledCourse.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index 30c2a63d2..5d0f527bb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.extension.isLinkValid +import org.openedx.core.extension.toImageLink import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -108,7 +109,6 @@ fun DiscoveryCourseItem( ) } - val imageUrl = apiHostUrl + course.media.courseImage?.uri Surface( modifier = Modifier .testTag("btn_course_card") @@ -126,7 +126,7 @@ fun DiscoveryCourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") .error(org.openedx.core.R.drawable.core_no_image_course) .placeholder(org.openedx.core.R.drawable.core_no_image_course) .build(), From bab3839385449368f43ccf4fdd277ba2e060906a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 18 Jul 2024 15:19:50 +0300 Subject: [PATCH 20/23] feat: clean offline progress when logging out --- .../main/java/org/openedx/app/room/DatabaseManager.kt | 2 +- .../java/org/openedx/core/module/db/CalendarDao.kt | 7 +++++++ .../java/org/openedx/core/module/db/DownloadDao.kt | 6 +++--- .../org/openedx/core/repository/CalendarRepository.kt | 11 +---------- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index f373e0e42..b4b0e5bc9 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -19,7 +19,7 @@ class DatabaseManager( CoroutineScope(Dispatchers.Main).launch { courseDao.clearCachedData() dashboardDao.clearCachedData() - downloadDao.clearCachedData() + downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() } } diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt index 0dcef5006..686009b92 100644 --- a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity @@ -55,4 +56,10 @@ interface CalendarDao { checksum: Int? = null, isCourseSyncEnabled: Boolean? = null ) + + @Transaction + suspend fun clearCachedData() { + clearCourseCalendarStateCachedData() + clearCourseCalendarEventsCachedData() + } } 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 95ee8c958..a07329e4d 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 @@ -32,9 +32,6 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) - @Query("DELETE FROM download_model") - suspend fun clearCachedData() - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) @@ -46,4 +43,7 @@ interface DownloadDao { @Query("DELETE FROM offline_x_block_progress_table WHERE id in (:ids)") suspend fun removeOfflineXBlockProgress(ids: List) + + @Query("DELETE FROM offline_x_block_progress_table") + suspend fun clearOfflineProgress() } diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt index e46922605..726709d8a 100644 --- a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -1,9 +1,5 @@ package org.openedx.core.repository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity @@ -55,12 +51,7 @@ class CalendarRepository( } suspend fun clearCalendarCachedData() { - CoroutineScope(Dispatchers.Main).launch { - val clearCourseCalendarStateDeferred = async { calendarDao.clearCourseCalendarStateCachedData() } - val clearCourseCalendarEventsDeferred = async { calendarDao.clearCourseCalendarEventsCachedData() } - clearCourseCalendarStateDeferred.await() - clearCourseCalendarEventsDeferred.await() - } + calendarDao.clearCachedData() } suspend fun updateCourseCalendarStateByIdInCache( From a8d6e8928fe6c6e0d9fe0a6ce1ef128114ae5cd0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 30 Jul 2024 14:27:22 +0300 Subject: [PATCH 21/23] fix: Release R8 build --- app/build.gradle | 3 +- app/proguard-rules.pro | 100 ++++++------------ auth/build.gradle | 1 + auth/proguard-rules.pro | 30 ++---- build.gradle | 4 +- core/build.gradle | 1 + core/consumer-rules.pro | 2 - core/proguard-rules.pro | 30 ++---- course/build.gradle | 1 + course/proguard-rules.pro | 28 ++--- .../data/repository/CourseRepository.kt | 1 + dashboard/build.gradle | 1 + dashboard/proguard-rules.pro | 28 ++--- discovery/build.gradle | 1 + discovery/proguard-rules.pro | 28 ++--- discussion/build.gradle | 1 + discussion/proguard-rules.pro | 28 ++--- gradle.properties | 1 + profile/build.gradle | 1 + profile/proguard-rules.pro | 28 ++--- settings.gradle | 2 +- whatsnew/build.gradle | 1 + whatsnew/proguard-rules.pro | 28 ++--- 23 files changed, 102 insertions(+), 247 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 659730ff0..cc09177bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,6 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' -apply plugin: 'fullstory' if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' @@ -30,6 +29,7 @@ if (firebaseEnabled) { } if (fullstoryEnabled) { + apply plugin: 'fullstory' def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") fullstory { @@ -107,6 +107,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc403e8f7..373a73186 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,66 +1,3 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -#====================/////Retrofit Rules\\\\\=============== -# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and -# EnclosingMethod is required to use InnerClasses. --keepattributes Signature, InnerClasses, EnclosingMethod - -# Retrofit does reflection on method and parameter annotations. --keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations - -# Keep annotation default values (e.g., retrofit2.http.Field.encoded). --keepattributes AnnotationDefault - -# Retain service method parameters when optimizing. --keepclassmembers,allowshrinking,allowobfuscation interface * { - @retrofit2.http.* ; -} - -# Ignore JSR 305 annotations for embedding nullability information. --dontwarn javax.annotation.** - -# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. --dontwarn kotlin.Unit - -# Top-level functions that can only be used by Kotlin. --dontwarn retrofit2.KotlinExtensions --dontwarn retrofit2.KotlinExtensions$* - -# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy -# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. --if interface * { @retrofit2.http.* ; } --keep,allowobfuscation interface <1> - -# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). --keep,allowobfuscation,allowshrinking interface retrofit2.Call --keep,allowobfuscation,allowshrinking class retrofit2.Response - -# With R8 full mode generic signatures are stripped for classes that are not -# kept. Suspend functions are wrapped in continuations where the type argument -# is used. --keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation - -#===============/////GSON RULES \\\\\\\============ ##---------------Begin: proguard configuration for Gson ---------- # Gson uses generic type information stored in a class file when working with fields. Proguard # removes such information by default, so configure it to keep all of it. @@ -69,12 +6,8 @@ # For using GSON @Expose annotation -keepattributes *Annotation* -# Gson specific classes --dontwarn sun.misc.** -#-keep class com.google.gson.stream.** { *; } - # Application classes that will be serialized/deserialized over Gson --keep class org.openedx.*.data.model.** { ; } +-keepclassmembers class org.openedx.**.data.model.** { *; } # Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) @@ -85,13 +18,13 @@ # Prevent R8 from leaving Data object members always null -keepclassmembers,allowobfuscation class * { + (); @com.google.gson.annotations.SerializedName ; } # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken - ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -108,4 +41,31 @@ -dontwarn org.conscrypt.ConscryptHostnameVerifier -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign$KeyPair +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign +-dontwarn com.google.crypto.tink.subtle.Ed25519Verify +-dontwarn com.google.crypto.tink.subtle.X25519 +-dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogFilterKind +-dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogTargetKt +-dontwarn edu.umd.cs.findbugs.annotations.NonNull +-dontwarn edu.umd.cs.findbugs.annotations.Nullable +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings +-dontwarn org.bouncycastle.asn1.ASN1Encodable +-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo +-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier +-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +-dontwarn org.bouncycastle.cert.X509CertificateHolder +-dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder +-dontwarn org.bouncycastle.crypto.BlockCipher +-dontwarn org.bouncycastle.crypto.CipherParameters +-dontwarn org.bouncycastle.crypto.InvalidCipherTextException +-dontwarn org.bouncycastle.crypto.engines.AESEngine +-dontwarn org.bouncycastle.crypto.modes.GCMBlockCipher +-dontwarn org.bouncycastle.crypto.params.AEADParameters +-dontwarn org.bouncycastle.crypto.params.KeyParameter +-dontwarn org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.openssl.PEMKeyPair +-dontwarn org.bouncycastle.openssl.PEMParser +-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter diff --git a/auth/build.gradle b/auth/build.gradle index 7cf4d0a86..cd6f00621 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -42,6 +42,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro index 82ef50a20..a054eb116 100644 --- a/auth/proguard-rules.pro +++ b/auth/proguard-rules.pro @@ -1,26 +1,12 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -if class androidx.credentials.CredentialManager -keep class androidx.credentials.playservices.** { *; } + +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/build.gradle b/build.gradle index 86658bc30..1dab497e9 100644 --- a/build.gradle +++ b/build.gradle @@ -37,10 +37,10 @@ ext { firebase_version = "33.0.0" - retrofit_version = '2.9.0' + retrofit_version = '2.11.0' logginginterceptor_version = '4.9.1' - koin_version = '3.2.0' + koin_version = '3.5.6' coil_version = '2.3.0' diff --git a/core/build.gradle b/core/build.gradle index 4022f0215..dce265ef3 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -80,6 +80,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro index 894a21021..e69de29bb 100644 --- a/core/consumer-rules.pro +++ b/core/consumer-rules.pro @@ -1,2 +0,0 @@ --dontwarn java.lang.invoke.StringConcatFactory --dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index a6be9313d..cdb308aa0 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -1,23 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/course/build.gradle b/course/build.gradle index f746f4d09..49946ca92 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/course/proguard-rules.pro b/course/proguard-rules.pro index 481bb4348..dccbe504f 100644 --- a/course/proguard-rules.pro +++ b/course/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 4ad5fc402..8eaafe721 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -109,6 +109,7 @@ class CourseRepository( } suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + suspend fun submitAllOfflineXBlockProgress() { val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() allOfflineXBlockProgress.forEach { diff --git a/dashboard/build.gradle b/dashboard/build.gradle index c0c3192d0..2fea01174 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discovery/build.gradle b/discovery/build.gradle index 881d8c05a..5e6e1887b 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -31,6 +31,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/discovery/proguard-rules.pro b/discovery/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discovery/proguard-rules.pro +++ b/discovery/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discussion/build.gradle b/discussion/build.gradle index 77d393d7a..70ed3c39f 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -28,6 +28,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/discussion/proguard-rules.pro b/discussion/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discussion/proguard-rules.pro +++ b/discussion/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/gradle.properties b/gradle.properties index cf0008ddc..d0a098a0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,4 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false +android.enableR8.fullMode=true diff --git a/profile/build.gradle b/profile/build.gradle index 1c3c6f301..2ccd98e63 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/profile/proguard-rules.pro b/profile/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/profile/proguard-rules.pro +++ b/profile/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/settings.gradle b/settings.gradle index 8f539415d..2e2262fff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,7 +14,7 @@ pluginManagement { maven { url "https://maven.fullstory.com" } } dependencies { - classpath("com.android.tools:r8:8.2.26") + classpath("com.android.tools:r8:8.3.37") classpath 'com.fullstory:gradle-plugin-local:1.47.0' } } diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 4a400063e..cd6778d05 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -30,6 +30,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/whatsnew/proguard-rules.pro b/whatsnew/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/whatsnew/proguard-rules.pro +++ b/whatsnew/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate From 2d8f3a514115f54d6ce2c3ba2d9dd700d5f83767 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 30 Jul 2024 14:37:15 +0300 Subject: [PATCH 22/23] refactor: clearTables Dispatchers.IO --- app/src/main/java/org/openedx/app/room/DatabaseManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index b4b0e5bc9..5d5415854 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -16,7 +16,7 @@ class DatabaseManager( private val discoveryDao: DiscoveryDao ) : DatabaseManager { override fun clearTables() { - CoroutineScope(Dispatchers.Main).launch { + CoroutineScope(Dispatchers.IO).launch { courseDao.clearCachedData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() From 1bf3c008b73f13d55fbbac9ece28126e724730f3 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 30 Jul 2024 18:38:18 +0300 Subject: [PATCH 23/23] fix: Fixes according to designer feedback --- .../course/presentation/container/CourseContainerTab.kt | 3 +-- course/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index fa0cf6c8e..255b7e88b 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem import org.openedx.course.R -import org.openedx.core.R as coreR enum class CourseContainerTab( @StringRes @@ -21,7 +20,7 @@ enum class CourseContainerTab( HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), - OFFLINE(coreR.string.core_offline, Icons.Outlined.CloudDownload), + OFFLINE(R.string.course_container_nav_downloads, Icons.Outlined.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 7663e9972..8be55b9d4 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ Discussions More Dates + Downloads Video player