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/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index c12e23bf8..b75825048 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -12,10 +12,12 @@ 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 com.braze.support.toStringMap 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 @@ -30,6 +32,7 @@ import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler +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 @@ -51,6 +54,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 calendarSyncScheduler by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -163,6 +167,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + lifecycleScope.launch { + viewModel.downloadFailedDialog.collect { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = it.downloadModel, + fragmentManager = supportFragmentManager, + ) + } + } + calendarSyncScheduler.scheduleDailySync() } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 43faf506f..69fc3a9d9 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -9,6 +9,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 @@ -20,6 +23,8 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.model.User 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.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.system.notifier.app.SignInEvent @@ -29,13 +34,14 @@ import org.openedx.core.utils.FileUtil @SuppressLint("StaticFieldLeak") 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 context: Context ) : BaseViewModel() { @@ -43,6 +49,11 @@ class AppViewModel( 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 @@ -66,7 +77,7 @@ class AppViewModel( } viewModelScope.launch { - notifier.notifier.collect { event -> + appNotifier.notifier.collect { event -> if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { SyncFirebaseTokenWorker.schedule(context) } else if (event is LogoutEvent) { @@ -74,6 +85,13 @@ class AppViewModel( } } } + viewModelScope.launch { + downloadNotifier.notifier.collect { event -> + if (event is DownloadFailed) { + _downloadFailedDialog.emit(event) + } + } + } } 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 795049d31..7cd9d7093 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,6 +35,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 @@ -57,6 +58,8 @@ import org.openedx.core.worker.CalendarSyncScheduler 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 @@ -89,6 +92,7 @@ val appModule = module { single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get()) } + single { DownloadDialogManager(get(), get(), get(), get()) } single { DatabaseManager(get(), get(), get(), get()) } single { get() } @@ -200,6 +204,9 @@ val appModule = module { factory { OAuthHelper(get(), get(), get()) } factory { FileUtil(get()) } + single { DownloadHelper(get(), get()) } + + factory { OfflineProgressSyncScheduler(get()) } single { CalendarSyncScheduler(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 ae550922c..541782caf 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -22,6 +22,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 @@ -83,7 +84,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { MainViewModel(get(), get(), get()) } @@ -271,6 +273,9 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), get() ) } @@ -281,11 +286,6 @@ val screenModule = module { get(), get(), get(), - get(), - get(), - get(), - get(), - get(), ) } viewModel { (courseId: String, unitId: String) -> @@ -296,6 +296,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -313,7 +314,10 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -437,10 +441,38 @@ val screenModule = module { get(), get(), get(), + get(), + ) + } + viewModel { (blockId: String, courseId: String) -> + HtmlUnitViewModel( + blockId, + courseId, + get(), + get(), + get(), + get(), + get(), + get(), ) } - viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String, courseTitle: String) -> + CourseOfflineViewModel( + courseId, + courseTitle, + get(), + get(), + 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 1728dfe9b..6aa46ed1f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity 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.CalendarDao import org.openedx.core.module.db.DownloadDao @@ -26,6 +27,7 @@ const val DATABASE_NAME = "OpenEdX_db" EnrolledCourseEntity::class, CourseStructureEntity::class, DownloadModelEntity::class, + OfflineXBlockProgress::class, CourseCalendarEventEntity::class, CourseCalendarStateEntity::class ], 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..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,10 +16,10 @@ class DatabaseManager( private val discoveryDao: DiscoveryDao ) : DatabaseManager { override fun clearTables() { - CoroutineScope(Dispatchers.Main).launch { + CoroutineScope(Dispatchers.IO).launch { courseDao.clearCachedData() dashboardDao.clearCachedData() - downloadDao.clearCachedData() + downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() } } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 6da7a144c..d2fb4897b 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -22,14 +22,15 @@ 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.core.system.notifier.app.LogoutEvent import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.FileUtil @@ -49,12 +50,14 @@ class AppViewModelTest { private val fileUtil = mockk() private val deepLinkRouter = mockk() private val context = mockk() + private val downloadNotifier = mockk() private val user = User(0, "", "", "") @Before fun before() { Dispatchers.setMain(dispatcher) + every { downloadNotifier.notifier } returns flow { } } @After @@ -79,7 +82,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -114,7 +118,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -151,7 +156,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() 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 c163d3982..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' @@ -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 c18b5ad0c..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 { @@ -165,6 +166,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/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/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/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index fab5d924b..4822a3762 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 @@ -13,7 +14,9 @@ import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body 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 @@ -78,6 +81,14 @@ interface CourseApi { @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + @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, + @Part progress: List + ) + @GET("/api/mobile/v1/users/{username}/enrollments_status/") suspend fun getEnrollmentsStatus( @Path("username") username: String 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/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/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/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/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..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 @@ -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, @@ -28,22 +31,24 @@ 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) || isxBlock } - val downloadableType: FileType - get() = when (type) { - BlockType.VIDEO -> { - FileType.VIDEO - } + val isxBlock: Boolean + get() = !offlineDownload?.fileUrl.isNullOrEmpty() - else -> { - FileType.UNKNOWN - } + val downloadableType: FileType? + get() = if (type == BlockType.VIDEO) { + FileType.VIDEO + } else if (isxBlock) { + FileType.X_BLOCK + } else { + null } fun isDownloading(): Boolean { @@ -89,14 +94,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 +111,7 @@ data class EncodedVideos( var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, var mobileLow: VideoInfo?, -) { +) : Parcelable { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || isPreferredVideoInfo(fallback) || @@ -184,11 +191,20 @@ 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/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index 06f052616..2071b6946 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" + 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() - return String.format( - "%." + round + "f", this / 1024.0.pow(digitGroups.toDouble()) - ) + " " + units[digitGroups] + val size = this / 1024.0.pow(digitGroups.toDouble()) + 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/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/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index 736a1b1ce..2186dbfc6 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -19,28 +19,29 @@ 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 +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 -import java.io.File class DownloadWorker( val context: Context, 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) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private var downloadEnqueue = listOf() + private var downloadError = mutableListOf() private val folder = FileUtil(context).getExternalAppDir() @@ -58,7 +59,6 @@ class DownloadWorker( return Result.success() } - private fun createForegroundInfo(): ForegroundInfo { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel() @@ -116,7 +116,7 @@ class DownloadWorker( folder.mkdir() } - downloadEnqueue = downloadDao.readAllData().first() + downloadEnqueue = downloadDao.getAllDataFlow().first() .map { it.mapToDomain() } .filter { it.downloadedState == DownloadedState.WAITING } @@ -131,21 +131,34 @@ class DownloadWorker( ) ) ) - val isSuccess = fileDownloader.download(downloadTask.url, downloadTask.path) - if (isSuccess) { - downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom( - downloadTask.copy( - downloadedState = DownloadedState.DOWNLOADED, - size = File(downloadTask.path).length().toInt() + val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) + when (downloadResult) { + DownloadResult.SUCCESS -> { + val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) + if (updatedModel == null) { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } else { + downloadDao.updateDownloadModel( + DownloadModelEntity.createFrom(updatedModel) ) - ) - ) - } else { - downloadDao.removeDownloadModel(downloadTask.id) + } + } + + DownloadResult.CANCELED -> { + downloadDao.removeDownloadModel(downloadTask.id) + } + + DownloadResult.ERROR -> { + 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/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index a4e83c07e..e440cfcc5 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 } } @@ -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/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/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 8005a4b95..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 @@ -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 { @@ -20,14 +21,29 @@ 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> + fun readAllDataByIds(ids: List): Flow> @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) + + @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) + + @Query("DELETE FROM offline_x_block_progress_table") + suspend fun clearOfflineProgress() } 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..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 @@ -1,15 +1,20 @@ package org.openedx.core.module.db +import android.os.Parcelable +import kotlinx.parcelize.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 { WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; @@ -26,5 +31,5 @@ enum class DownloadedState { } enum class FileType { - VIDEO, UNKNOWN -} \ No newline at end of file + VIDEO, X_BLOCK +} 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/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 40144325e..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 @@ -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,20 +56,23 @@ 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() } } - suspend fun cancelDownloading() { isCanceled = true withContext(Dispatchers.IO) { @@ -88,4 +91,7 @@ abstract class AbstractDownloader : KoinComponent { } } -} \ No newline at end of file + enum class DownloadResult { + SUCCESS, CANCELED, ERROR + } +} 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..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 @@ -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,9 +24,10 @@ abstract class BaseDownloadViewModel( private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, + private val downloadHelper: DownloadHelper, ) : BaseViewModel() { - private val allBlocks = hashMapOf() + val allBlocks = hashMapOf() private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() @@ -42,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) @@ -56,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) { @@ -121,33 +120,16 @@ 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) { 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) } } } @@ -212,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 -> @@ -229,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 new file mode 100644 index 000000000..7c687f58e --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -0,0 +1,113 @@ +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.X_BLOCK -> { + val url = if (block.downloadableType == FileType.X_BLOCK) { + 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.X_BLOCK -> { + val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) ?: return null + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + 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/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( 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/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/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/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 26806897f..eb9f92800 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/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/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 9aded8c31..00b02502a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -182,6 +182,10 @@ Discussions More Dates + Confirm Download + Edit + Offline Progress Sync + Close Calendar Sync Failed Synced to Calendar 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..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 @@ -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/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 c32397a48..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 @@ -1,9 +1,12 @@ 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 +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 @@ -11,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, @@ -25,12 +30,19 @@ 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) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } } suspend fun getCourseStructure(courseId: String, isNeedRefresh: Boolean): CourseStructure { @@ -39,7 +51,7 @@ class CourseRepository( if (networkConnection.isOnline()) { val response = api.getCourseStructure( "stale-if-error=0", - "v3", + "v4", preferencesManager.user?.username, courseId ) @@ -86,4 +98,41 @@ 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 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/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 5bc859120..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 @@ -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 @@ -72,4 +76,16 @@ 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/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/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 9168d3148..9e3db405c 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 @@ -437,6 +438,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..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 @@ -6,6 +6,7 @@ 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 @@ -19,6 +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(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/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 8d0f404c3..d30d68c00 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 @@ -23,6 +23,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.ResourceManager @@ -193,7 +194,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 { @@ -219,6 +220,10 @@ class CourseContainerViewModel( updateData() } + CourseContainerTab.OFFLINE -> { + updateData() + } + CourseContainerTab.DATES -> { viewModelScope.launch { courseNotifier.send(RefreshDates) @@ -262,6 +267,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 69f6e0559..b148c8acb 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 @@ -100,7 +100,7 @@ fun CourseDatesScreen( isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState(DatesUIState.Loading) + val uiState by viewModel.uiState.collectAsState(CourseDatesUIState.Loading) val uiMessage by viewModel.uiMessage.collectAsState(null) val context = LocalContext.current @@ -175,7 +175,7 @@ fun CourseDatesScreen( @Composable private fun CourseDatesUI( windowSize: WindowSize, - uiState: DatesUIState, + uiState: CourseDatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, onItemClick: (CourseDateBlock) -> Unit, @@ -210,7 +210,7 @@ private fun CourseDatesUI( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - val isPLSBannerAvailable = (uiState as? DatesUIState.Dates) + val isPLSBannerAvailable = (uiState as? CourseDatesUIState.CourseDates) ?.courseDatesResult ?.courseBanner ?.isBannerAvailableForUserType(isSelfPaced) @@ -236,7 +236,7 @@ private fun CourseDatesUI( .fillMaxWidth() ) { when (uiState) { - is DatesUIState.Dates -> { + is CourseDatesUIState.CourseDates -> { LazyColumn( modifier = Modifier .fillMaxSize() @@ -332,7 +332,7 @@ private fun CourseDatesUI( } } - DatesUIState.Empty -> { + CourseDatesUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -347,7 +347,7 @@ private fun CourseDatesUI( } } - DatesUIState.Loading -> {} + CourseDatesUIState.Loading -> {} } } } @@ -677,7 +677,7 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates( + uiState = CourseDatesUIState.CourseDates( CourseDatesResult(mockedResponse, mockedCourseBannerInfo), CalendarSyncState.SYNCED ), @@ -698,7 +698,7 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates( + uiState = CourseDatesUIState.CourseDates( CourseDatesResult(mockedResponse, mockedCourseBannerInfo), CalendarSyncState.SYNCED ), 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..5623129d0 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +sealed interface CourseDatesUIState { + data class CourseDates( + val courseDatesResult: CourseDatesResult, + val calendarSyncState: CalendarSyncState, + ) : 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 addad3199..589c103fc 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 @@ -54,8 +54,8 @@ class CourseDatesViewModel( var isSelfPaced = true - private val _uiState = MutableStateFlow(DatesUIState.Loading) - val uiState: StateFlow + private val _uiState = MutableStateFlow(CourseDatesUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() @@ -82,7 +82,7 @@ class CourseDatesViewModel( (_uiState.value as? DatesUIState.Dates)?.let { currentUiState -> val courseDates = currentUiState.courseDatesResult.datesSection.values.flatten() _uiState.update { - (it as DatesUIState.Dates).copy(calendarSyncState = getCalendarState(courseDates)) + (it as CourseDatesUIState.CourseDates).copy(calendarSyncState = getCalendarState(courseDates)) } } } @@ -98,11 +98,11 @@ 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 { val courseDates = datesResponse.datesSection.values.flatten() val calendarState = getCalendarState(courseDates) - _uiState.value = DatesUIState.Dates(datesResponse, calendarState) + _uiState.value = CourseDatesUIState.CourseDates(datesResponse, calendarState) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } @@ -156,7 +156,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/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt new file mode 100644 index 000000000..1c220903f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -0,0 +1,264 @@ +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.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.vector.ImageVector +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.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 +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(1, 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, + sizeSumString + ), + icon = painterResource(id = coreR.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)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + + val buttonText: String + val buttonIcon: ImageVector + val buttonColor: ComposeColor + val onClick: () -> Unit + when (dialogType) { + DownloadConfirmDialogType.REMOVE -> { + buttonText = stringResource(id = R.string.course_remove) + buttonIcon = Icons.Rounded.Delete + buttonColor = MaterialTheme.appColors.error + onClick = onRemoveClick + } + + else -> { + buttonText = stringResource(id = R.string.course_download) + buttonIcon = Icons.Outlined.CloudDownload + buttonColor = MaterialTheme.appColors.secondaryButtonBackground + onClick = onConfirmClick + } + } + OpenEdXButton( + text = buttonText, + backgroundColor = buttonColor, + onClick = onClick, + content = { + IconText( + text = buttonText, + icon = buttonIcon, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.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, + isDownloadFailed = false, + saveDownloadModels = {}, + removeDownloadModels = {}, + fragmentManager = PreviewFragmentManager + ), + 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..9c0833ff3 --- /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 +} 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..9f3cfc4d4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt @@ -0,0 +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 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 new file mode 100644 index 000000000..64a95d2d8 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -0,0 +1,263 @@ +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.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 + +class DownloadDialogManager( + private val networkConnection: NetworkConnection, + private val corePreferences: CorePreferences, + private val interactor: CourseInteractor, + private val workerController: DownloadWorkerController +) { + + companion object { + const val MAX_CELLULAR_SIZE = 104857600 // 100MB + const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + } + + private val uiState = MutableSharedFlow() + + init { + 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, + 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 + ) + } + + StorageManager.getFreeStorage() < uiState.sizeSum * DOWNLOAD_SIZE_FACTOR -> { + 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 = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + uiState.sizeSum >= MAX_CELLULAR_SIZE -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + else -> { + uiState.saveDownloadModels() + } + } + } + } + } + + fun showPopup( + subSectionsBlocks: List, + courseId: String, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean = false, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + createDownloadItems( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + onlyVideoBlocks = onlyVideoBlocks, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels + ) + } + + 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, + ) { + 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) } + 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, + 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 = 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 + } + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + ) + ) + } + } + + + private fun getFileSize(block: Block): Long { + 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 new file mode 100644 index 000000000..b58e856bd --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -0,0 +1,17 @@ +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, + val isDownloadFailed: Boolean, + val fragmentManager: @RawValue FragmentManager, + val removeDownloadModels: () -> Unit, + val saveDownloadModels: () -> Unit +) : 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 new file mode 100644 index 000000000..05d7e0243 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -0,0 +1,222 @@ +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.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 +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, + dialogType = dialogType, + onTryAgainClick = { + uiState.saveDownloadModels() + dismiss() + }, + 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, + dialogType: DownloadErrorDialogType, + onTryAgainClick: () -> Unit, + 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 + ) { + 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)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + 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 = dismissButtonText, + 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, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {}, + onTryAgainClick = {}, + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED + ) + } +} 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..85f01cf1a --- /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 +} 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..0059f2bec --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -0,0 +1,283 @@ +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.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.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.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.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() { + + 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 = 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( + 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)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) + } + } + StorageBar( + freeSpace = StorageManager.getFreeStorage(), + totalSpace = StorageManager.getTotalStorage(), + requiredSpace = uiState.sizeSum * DOWNLOAD_SIZE_FACTOR + ) + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Composable +private fun StorageBar( + freeSpace: Long, + totalSpace: Long, + requiredSpace: Long +) { + val cornerRadius = 2.dp + val boxPadding = 1.dp + val usedSpace = totalSpace - freeSpace + 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) { + animReqPercentage.animateTo( + targetValue = reqPercentage, + animationSpec = tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + 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), + ) { + Box( + modifier = Modifier + .weight(freePercentage) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) + .background(MaterialTheme.appColors.cardViewBorder) + ) + Box( + modifier = Modifier + .weight(animReqPercentage.value) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) + .background(MaterialTheme.appColors.error) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.course_used_free_storage, + usedSpace.toFileSize(1, false), + freeSpace.toFileSize(1, false) + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint, + modifier = Modifier.weight(1f) + ) + Text( + text = requiredSpace.toFileSize(1, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.error, + ) + } + } +} + +@Preview +@Composable +private fun DownloadStorageErrorDialogViewPreview() { + OpenEdXTheme { + DownloadStorageErrorDialogView( + 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, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {} + ) + } +} 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..2a760c772 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -0,0 +1,59 @@ +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.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 +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +fun DownloadDialogItem( + modifier: Modifier = Modifier, + 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, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Top), + painter = icon, + tint = MaterialTheme.appColors.textDark, + contentDescription = null, + ) + Text( + modifier = Modifier.weight(1f), + text = downloadDialogItem.title, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + Text( + 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 new file mode 100644 index 000000000..cdad27742 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -0,0 +1,489 @@ +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 +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.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.Divider +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +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 +import androidx.compose.runtime.LaunchedEffect +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.OpenEdXOutlinedButton +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 +import org.openedx.core.R as coreR + +@Composable +fun CourseOfflineScreen( + windowSize: WindowSize, + viewModel: CourseOfflineViewModel, + fragmentManager: FragmentManager, +) { + val uiState by viewModel.uiState.collectAsState() + + CourseOfflineUI( + windowSize = windowSize, + uiState = uiState, + hasInternetConnection = viewModel.hasInternetConnection, + onDownloadAllClick = { + viewModel.downloadAllBlocks(fragmentManager) + }, + onCancelDownloadClick = { + viewModel.removeDownloadModel() + }, + onDeleteClick = { downloadModel -> + viewModel.removeDownloadModel( + downloadModel, + fragmentManager + ) + }, + onDeleteAllClick = { + viewModel.deleteAll(fragmentManager) + }, + ) +} + +@Composable +private fun CourseOfflineUI( + windowSize: WindowSize, + uiState: CourseOfflineUIState, + hasInternetConnection: Boolean, + onDownloadAllClick: () -> Unit, + onCancelDownloadClick: () -> Unit, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> 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, + ) { + LazyColumn( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 24.dp) + .then(horizontalPadding) + ) { + item { + if (uiState.isHaveDownloadableBlocks) { + DownloadProgress( + uiState = uiState, + ) + } else { + NoDownloadableBlocksProgress() + } + if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { + 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 + ) + } + ) + } 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, + ) + } + } + } + } + } + } +} + +@Composable +private fun LargestDownloads( + largestDownloads: List, + isDownloading: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit, +) { + var isEditingEnabled by rememberSaveable { + mutableStateOf(false) + } + val text = if (!isEditingEnabled) { + stringResource(coreR.string.core_edit) + } else { + stringResource(coreR.string.core_label_done) + } + + LaunchedEffect(isDownloading) { + if (isDownloading) { + isEditingEnabled = false + } + } + + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.course_largest_downloads), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + 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 { + DownloadItem( + downloadModel = it, + isEditingEnabled = isEditingEnabled, + onDeleteClick = onDeleteClick + ) + } + 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 + ) + } + ) + } + } +} + +@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(1, 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, + 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() + .height(40.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconText( + text = stringResource(R.string.course_downloaded), + icon = Icons.Default.CloudDone, + color = MaterialTheme.appColors.successGreen, + 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 { + IconText( + text = stringResource(R.string.course_downloading), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + if (uiState.progressBarValue != 0f) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape), + progress = uiState.progressBarValue, + strokeCap = StrokeCap.Round, + 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 + ) + } + } +} + +@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(), + hasInternetConnection = true, + uiState = CourseOfflineUIState( + isHaveDownloadableBlocks = true, + readyToDownloadSize = "159MB", + downloadedSize = "0MB", + progressBarValue = 0f, + isDownloading = true, + largestDownloads = listOf( + DownloadModel( + "", + "", + "", + 0, + "", + "", + FileType.X_BLOCK, + DownloadedState.DOWNLOADED, + null + ) + ), + ), + onDownloadAllClick = {}, + onCancelDownloadClick = {}, + onDeleteClick = {}, + onDeleteAllClick = {} + ) + } +} 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..8abde204f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -0,0 +1,12 @@ +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, + 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..230b30deb --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -0,0 +1,216 @@ +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.async +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 +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.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 +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 networkConnection: NetworkConnection, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + private val _uiState = MutableStateFlow( + CourseOfflineUIState( + isHaveDownloadableBlocks = false, + largestDownloads = emptyList(), + isDownloading = false, + readyToDownloadSize = "", + downloadedSize = "", + progressBarValue = 0f, + ) + ) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + viewModelScope.launch { + downloadModelsStatusFlow.collect { + val isDownloading = it.any { it.value.isWaitingOrDownloading } + _uiState.update { it.copy(isDownloading = isDownloading) } + } + } + + viewModelScope.launch { + async { initDownloadFragment() }.await() + getOfflineData() + } + } + + fun downloadAllBlocks(fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val downloadModels = courseInteractor.getAllDownloadModels() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + 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, + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + + 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) + }, + ) + } + + fun deleteAll(fragmentManager: FragmentManager) { + viewModelScope.launch { + 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 = { + downloadModels.forEach { + super.removeBlockDownloadModel(it.id) + } + }, + ) + } + } + + 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) + 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) + 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) + val realDownloadedFilesSize = downloadModels.sumOf { it.size } + val largestDownloads = downloadModels + .sortedByDescending { it.size } + .take(5) + + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, + readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(1, false), + downloadedSize = realDownloadedFilesSize.toFileSize(1, 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/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 1f31b32de..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 @@ -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 @@ -104,7 +105,7 @@ fun CourseOutlineScreen( } }, onSubSectionClick = { subSectionBlock -> - if (viewModel.isCourseNestedListEnabled) { + if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> viewModel.logUnitDetailViewedEvent( unit.blockId, @@ -139,8 +140,7 @@ fun CourseOutlineScreen( onDownloadClick = { blocksIds -> viewModel.downloadBlocks( blocksIds = blocksIds, - fragmentManager = fragmentManager, - context = context + fragmentManager = fragmentManager ) }, onResetDatesClick = { @@ -581,7 +581,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 +601,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 b65b3b62a..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 @@ -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 @@ -20,12 +19,14 @@ 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 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 @@ -43,7 +44,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.R as courseR +import org.openedx.course.presentation.download.DownloadDialogManager class CourseOutlineViewModel( val courseId: String, @@ -55,18 +56,22 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -89,6 +94,8 @@ class CourseOutlineViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() + private var isOfflineBlocksUpToDate = false + init { viewModelScope.launch { courseNotifier.notifier.collect { event -> @@ -126,20 +133,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() } @@ -203,6 +196,7 @@ class CourseOutlineViewModel( val datesBannerInfo = courseDatesResult.courseBanner checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) setBlocks(blocks) courseSubSections.clear() @@ -388,23 +382,87 @@ class CourseOutlineViewModel( } } - fun downloadBlocks( - blocksIds: List, - fragmentManager: FragmentManager, - context: Context - ) { - if (blocksIds.find { isBlockDownloading(it) } != null) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) - return - } - blocksIds.forEach { blockId -> - if (isBlockDownloaded(blockId)) { - removeDownloadModels(blockId) + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + 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 + } + } + + 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 { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } ) } } } + + 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/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 0c83b264b..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,13 +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.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,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 @@ -65,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 @@ -79,12 +73,9 @@ 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 @@ -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,47 +329,6 @@ private fun CourseSubsectionItem( horizontalArrangement = Arrangement.spacedBy(24.dp), 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) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - 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) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } 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 ) @@ -427,14 +361,12 @@ private fun CourseSectionScreenPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default" ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -453,14 +385,12 @@ private fun CourseSectionScreenTabletPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default", ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -482,5 +412,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/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 33870c69c..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,20 +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.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 @@ -30,20 +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, -) : BaseDownloadViewModel( - courseId, - downloadDao, - preferencesManager, - workerController, - coreAnalytics -) { +) : BaseViewModel() { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData @@ -57,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) { @@ -93,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 ) @@ -116,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() @@ -140,7 +90,6 @@ class CourseSectionViewModel( if (blockDescendant != null) { if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant) - addDownloadableChildrenForVerticalBlock(blockDescendant) } } else continue } 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 b111dd1f0..f1bbe6086 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 @@ -161,10 +161,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) { @@ -175,7 +175,7 @@ fun CourseSectionCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) @@ -609,7 +609,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) @@ -626,7 +625,7 @@ fun CourseSection( arrowDegrees = arrowRotation, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(downloadBlockIds) + onDownloadClick(block.descendants) } ) courseSubSections?.forEach { subSectionBlock -> @@ -685,11 +684,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) { @@ -706,7 +705,7 @@ fun CourseExpandableChapterCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick() }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = downloadIconTint ) @@ -1277,6 +1276,7 @@ private fun OfflineQueueCardPreview() { Surface(color = MaterialTheme.appColors.background) { OfflineQueueCard( downloadModel = DownloadModel( + courseId = "", id = "", title = "Problems of society", size = 4000, @@ -1284,7 +1284,6 @@ private fun OfflineQueueCardPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), progressValue = 10, progressSize = 30, @@ -1332,5 +1331,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..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 @@ -103,15 +103,24 @@ 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.VIDEOS + ) + } + } 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, + subSectionId = subSectionBlock.id, mode = CourseViewMode.VIDEOS ) } @@ -120,7 +129,6 @@ fun CourseVideosScreen( viewModel.downloadBlocks( blocksIds = blocksIds, fragmentManager = fragmentManager, - context = context ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> @@ -718,7 +726,8 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( @@ -738,7 +747,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/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 6d37954ee..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 @@ -4,12 +4,14 @@ 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 import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import java.io.File class CourseUnitContainerAdapter( fragment: Fragment, @@ -22,73 +24,92 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) private fun unitBlockFragment(block: Block): Fragment { + val downloadedModel = viewModel.getDownloadModelById(block.id) + val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" + val noNetwork = !viewModel.hasNetworkConnection + 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 - ) - } - } + noNetwork && block.isDownloadable && offlineUrl.isEmpty() -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } - (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { - DiscussionThreadsFragment.newInstance( - DiscussionTopicsViewModel.TOPIC, - viewModel.courseId, - block.studentViewData?.topicId ?: "", - block.displayName, - FragmentViewType.MAIN_CONTENT.name, - block.id - ) + noNetwork && !block.isDownloadable -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } - block.studentViewMultiDevice.not() -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + block.isVideoBlock && block.studentViewData?.encodedVideos?.run { hasVideoUrl || hasYoutubeUrl } == true -> { + createVideoFragment(block) } - block.isHTMLBlock || - block.isProblemBlock || - block.isOpenAssessmentBlock || - block.isDragAndDropBlock || - block.isWordCloudBlock || - block.isLTIConsumerBlock || - block.isSurveyBlock -> { - HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) + block.isDiscussionBlock && !block.studentViewData?.topicId.isNullOrEmpty() -> { + createDiscussionFragment(block) } - else -> { - NotSupportedUnitFragment.newInstance( + !block.studentViewMultiDevice -> { + 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 { + "" + } + HtmlUnitFragment.newInstance( block.id, - block.lmsWebUrl + block.studentViewUrl, + viewModel.courseId, + offlineUrl, + lastModified ) } + + else -> { + 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/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..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 @@ -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 @@ -32,6 +33,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 @@ -49,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 @@ -64,14 +67,23 @@ 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 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, "") + lastModified = requireArguments().getString(ARG_LAST_MODIFIED, "") + fromDownloadedContent = lastModified.isNotEmpty() } override fun onCreateView( @@ -92,7 +104,18 @@ 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 uiState by viewModel.uiState.collectAsState() val configuration = LocalConfiguration.current @@ -125,43 +148,49 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - HTMLContentView( - windowSize = windowSize, - url = blockUrl, - 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) + 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) + }, + ) + } 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) + } } } } @@ -173,15 +202,24 @@ 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_LAST_MODIFIED = "lastModified" fun newInstance( blockId: String, blockUrl: String, + courseId: 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_COURSE_ID to courseId ) return fragment } @@ -191,6 +229,7 @@ class HtmlUnitFragment : Fragment() { @Composable @SuppressLint("SetJavaScriptEnabled") private fun HTMLContentView( + uiState: HtmlUnitUIState, windowSize: WindowSize, url: String, cookieManager: AppCookieManager, @@ -200,6 +239,7 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, + saveXBlockProgress: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -228,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?) { @@ -290,7 +341,10 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true - + allowFileAccess = true + allowContentAccess = true + useWideViewPort = true + cacheMode = WebSettings.LOAD_NO_CACHE } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -302,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..2dc14424c --- /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 +) 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..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,8 +2,11 @@ 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 +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config @@ -12,14 +15,24 @@ 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 +import org.openedx.course.worker.OfflineProgressSyncScheduler 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, + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler ) : 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 +41,10 @@ class HtmlUnitViewModel( val apiHostURL get() = config.getApiHostURL() val cookieManager get() = edxCookieManager + init { + tryToSyncProgress() + } + fun setWebPageLoaded(assets: AssetManager) { if (_injectJSList.value.isNotEmpty()) return @@ -39,6 +56,7 @@ class HtmlUnitViewModel( assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } _injectJSList.value = jsList + getXBlockProgress() } fun notifyCompletionSet() { @@ -46,4 +64,34 @@ class HtmlUnitViewModel( notifier.send(CourseCompletionSet()) } } + + fun saveXBlockProgress(jsonProgress: String) { + viewModelScope.launch { + courseInteractor.saveXBlockProgress(blockId, courseId, jsonProgress) + offlineProgressSyncScheduler.scheduleSync() + } + } + + 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() { + 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/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index a5bf069cd..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 @@ -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 @@ -19,6 +18,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 @@ -32,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, @@ -44,19 +45,23 @@ 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, - workerController: DownloadWorkerController + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow @@ -214,21 +219,55 @@ class CourseVideoViewModel( return resultBlocks.toList() } - fun downloadBlocks( - blocksIds: List, - fragmentManager: FragmentManager, - context: Context - ) { - if (blocksIds.find { isBlockDownloading(it) } != null) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) - return - } - blocksIds.forEach { blockId -> - 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 { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + onlyVideoBlocks = true, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } ) } } 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() 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/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt new file mode 100644 index 000000000..d41e9909e --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -0,0 +1,82 @@ +package org.openedx.course.worker + +import android.app.NotificationChannel +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 +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.course.domain.interactor.CourseInteractor + +class OfflineProgressSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val courseInteractor: CourseInteractor 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) { + Log.e(WORKER_TAG, "$e") + 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() { + 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/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/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 51ac39e95..8be55b9d4 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: @@ -46,6 +46,7 @@ Discussions More Dates + Downloads Video player @@ -63,6 +64,36 @@ 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 %1$s of cellular data. + Remove Offline Content? + 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 + 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. + Downloading + 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 assignment 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 ca0c18c79..2fb055011 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 @@ -176,7 +176,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 @@ -205,7 +205,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 @@ -234,7 +234,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 @@ -266,6 +266,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/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..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 @@ -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(), 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..45ff2a72f 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,18 +190,13 @@ 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, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() @@ -214,18 +215,13 @@ 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, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { interactor.getCourseStructure(any()) } throws Exception() @@ -244,23 +240,18 @@ 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( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } coEvery { interactor.getCourseStructure(any()) } returns courseStructure @@ -278,27 +269,21 @@ 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( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) @@ -306,63 +291,29 @@ 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( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) } - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - val viewModel = CourseSectionViewModel( - "", - interactor, - resourceManager, - networkConnection, - preferencesManager, - notifier, - analytics, - coreAnalytics, - workerController, - downloadDao, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - - viewModel.saveDownloadModels("", "") - - advanceUntilIdle() - - assert(viewModel.uiMessage.value != null) - } - - @Test fun `updateVideos success`() = runTest { - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -372,13 +323,8 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { notifier.notifier } returns flow { } @@ -394,7 +340,6 @@ class CourseSectionViewModelTest { advanceUntilIdle() assert(viewModel.uiState.value is CourseSectionUIState.Blocks) - } } 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..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 @@ -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 @@ -51,10 +52,12 @@ import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.utils.FileUtil 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 import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -76,6 +79,9 @@ class CourseVideoViewModelTest { private val downloadDao = mockk() private val workerController = mockk() private val courseRouter = mockk() + private val downloadHelper = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -102,7 +108,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -120,7 +127,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -138,7 +146,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -168,11 +177,12 @@ class CourseVideoViewModelTest { ) private val downloadModelEntity = - DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + DownloadModelEntity("", "", "", 1, "", "", "VIDEO", "DOWNLOADED", null) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -188,6 +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(), any()) } returns Unit } @After @@ -200,7 +211,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( "", @@ -213,10 +224,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, workerController, + downloadHelper, ) viewModel.getVideos() @@ -231,7 +245,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( @@ -245,10 +259,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) @@ -267,7 +284,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()) @@ -285,10 +302,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -308,7 +328,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() } @@ -327,13 +347,16 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, 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 @@ -364,17 +387,20 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, 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 @@ -405,16 +431,19 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, 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/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/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index c2668f766..e7e22ba1c 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/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 } 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 a6d375569..5de4c78c5 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/default_config/dev/config.yaml b/default_config/dev/config.yaml index a97d7c351..99f2f0f3d 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -88,3 +88,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 a97d7c351..99f2f0f3d 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -88,3 +88,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 a97d7c351..99f2f0f3d 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -88,3 +88,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/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/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(), 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/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() { 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