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 96fc024d0..4c3aa59cd 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.presentation.info.CourseInfoViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.html.HtmlUnitViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel @@ -256,4 +257,12 @@ val screenModule = module { } viewModel { (courseId: String) -> WhatsNewViewModel(courseId, get()) } + viewModel { + HtmlUnitViewModel( + get(), + get(), + get(), + get() + ) + } } diff --git a/core/src/main/java/org/openedx/core/BlockType.kt b/core/src/main/java/org/openedx/core/BlockType.kt index 9edfbdafd..07a7bf882 100644 --- a/core/src/main/java/org/openedx/core/BlockType.kt +++ b/core/src/main/java/org/openedx/core/BlockType.kt @@ -14,7 +14,8 @@ enum class BlockType { SEQUENTIAL{ override fun isContainer() = true }, VERTICAL{ override fun isContainer() = true }, VIDEO{ override fun isContainer() = false }, - WORD_CLOUD{ override fun isContainer() = false }; + WORD_CLOUD{ override fun isContainer() = false }, + SURVEY{ override fun isContainer() = false }; abstract fun isContainer() : Boolean 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 fe5f1eb6d..1c69142a8 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 @@ -83,6 +83,7 @@ data class Block( val isDragAndDropBlock get() = type == BlockType.DRAG_AND_DROP_V2 val isWordCloudBlock get() = type == BlockType.WORD_CLOUD val isLTIConsumerBlock get() = type == BlockType.LTI_CONSUMER + val isSurveyBlock get() = type == BlockType.SURVEY } data class StudentViewData( diff --git a/core/src/main/java/org/openedx/core/extension/AssetExt.kt b/core/src/main/java/org/openedx/core/extension/AssetExt.kt new file mode 100644 index 000000000..190f68721 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/AssetExt.kt @@ -0,0 +1,15 @@ +package org.openedx.core.extension + +import android.content.res.AssetManager +import android.util.Log +import java.io.BufferedReader + +fun AssetManager.readAsText(fileName: String): String? { + return try { + open(fileName).bufferedReader().use(BufferedReader::readText) + } catch (e: Exception) { + Log.e("AssetExt", "Unable to load file $fileName from assets") + e.printStackTrace() + null + } +} diff --git a/course/src/main/assets/js_injection/completions.js b/course/src/main/assets/js_injection/completions.js new file mode 100644 index 000000000..329de07d8 --- /dev/null +++ b/course/src/main/assets/js_injection/completions.js @@ -0,0 +1,8 @@ +//Injection to intercept completion state for xBlocks +$(document).on("ajaxSuccess", function(event, request, settings) { + console.log("loaded url is = " + settings.url); + if (settings.url.includes("publish_completion") && + request.responseText.includes("ok")) { + javascript:window.callback.completionSet(); + } +}); diff --git a/course/src/main/assets/js_injection/survey_css.js b/course/src/main/assets/js_injection/survey_css.js new file mode 100644 index 000000000..051fa4167 --- /dev/null +++ b/course/src/main/assets/js_injection/survey_css.js @@ -0,0 +1,28 @@ +//Injection to fix CSS issues for Survey xBlock +var css = ` + .survey-table:not(.poll-results) .survey-option label { + margin-bottom: 0px !important; + } + + .survey-table:not(.poll-results) .survey-option .visible-mobile-only { + width: calc(100% - 21px) !important; + } + + .survey-table:not(.poll-results) .survey-option input { + width: 13px !important; + height: 13px !important; + } + + .survey-percentage .percentage { + width: 54px !important; + }`; +var head = document.head || document.getElementsByTagName('head')[0]; +var style = document.createElement('style'); + +head.appendChild(style); +style.type = 'text/css'; +if (style.styleSheet) { + style.styleSheet.cssText = css; +} else { + style.appendChild(document.createTextNode(css)); +} 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 7d1610f89..5dc12b617 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 @@ -88,7 +88,8 @@ class CourseUnitContainerAdapter( block.isOpenAssessmentBlock || block.isDragAndDropBlock || block.isWordCloudBlock || - block.isLTIConsumerBlock -> { + block.isLTIConsumerBlock || + block.isSurveyBlock -> { HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) } 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 5a3be6df4..124ceb327 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 @@ -3,6 +3,7 @@ package org.openedx.course.presentation.unit.html import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration +import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.util.Log @@ -29,30 +30,18 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject -import org.openedx.core.config.Config +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.extension.isEmailValid 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.core.ui.ConnectionErrorView -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.roundBorderWithoutBottom +import org.openedx.core.ui.* import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil class HtmlUnitFragment : Fragment() { - private val config by inject() - private val edxCookieManager by inject() - private val networkConnection by inject() - private val notifier by inject() + private val viewModel by viewModel() private var blockId: String = "" private var blockUrl: String = "" @@ -77,18 +66,21 @@ class HtmlUnitFragment : Fragment() { } var hasInternetConnection by remember { - mutableStateOf(networkConnection.isOnline()) + mutableStateOf(viewModel.isOnline) } + val injectJSList by viewModel.injectJSList.collectAsState() + val configuration = LocalConfiguration.current - val bottomPadding = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { - 72.dp - } else { - 0.dp - } + val bottomPadding = + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + 72.dp + } else { + 0.dp + } - val border = if (!isSystemInDarkTheme() && !config.isCourseUnitProgressEnabled()) { + val border = if (!isSystemInDarkTheme() && !viewModel.isCourseUnitProgressEnabled) { Modifier.roundBorderWithoutBottom( borderWidth = 2.dp, cornerRadius = 30.dp @@ -114,14 +106,19 @@ class HtmlUnitFragment : Fragment() { HTMLContentView( windowSize = windowSize, url = blockUrl, - cookieManager = edxCookieManager, + cookieManager = viewModel.cookieManager, + apiHostURL = viewModel.apiHostURL, + isLoading = isLoading, + injectJSList = injectJSList, onCompletionSet = { - lifecycleScope.launch { - notifier.send(CourseCompletionSet()) - } + viewModel.notifyCompletionSet() + }, + onWebPageLoading = { + isLoading = true }, onWebPageLoaded = { isLoading = false + viewModel.setWebPageLoaded(requireContext().assets) } ) } else { @@ -131,7 +128,7 @@ class HtmlUnitFragment : Fragment() { .fillMaxHeight() .background(MaterialTheme.appColors.background) ) { - hasInternetConnection = networkConnection.isOnline() + hasInternetConnection = viewModel.isOnline } } if (isLoading && hasInternetConnection) { @@ -174,7 +171,11 @@ private fun HTMLContentView( windowSize: WindowSize, url: String, cookieManager: AppCookieManager, + apiHostURL: String, + isLoading: Boolean, + injectJSList: List, onCompletionSet: () -> Unit, + onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -204,21 +205,15 @@ private fun HTMLContentView( }, "callback") webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + onWebPageLoading() + } + override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) Log.d("HTML", "onPageCommitVisible") onWebPageLoaded() - - evaluateJavascript( - """ - ${'$'}(document).ajaxSuccess(function(event, request, settings) { - if (settings.url.includes("publish_completion") && - request.responseText.includes("ok")) { - javascript:window.callback.completionSet(); - } - }); - """.trimIndent(), null - ) } override fun shouldOverrideUrlLoading( @@ -250,7 +245,7 @@ private fun HTMLContentView( request: WebResourceRequest, errorResponse: WebResourceResponse, ) { - if (request.url.toString() == view.url) { + if (request.url.toString().startsWith(apiHostURL)) { when (errorResponse.statusCode) { 403, 401, 404 -> { coroutineScope.launch { @@ -275,6 +270,11 @@ private fun HTMLContentView( isHorizontalScrollBarEnabled = false loadUrl(url) } + }, + update = { webView -> + if (!isLoading && injectJSList.isNotEmpty()) { + injectJSList.forEach { webView.evaluateJavascript(it, null) } + } }) } 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 new file mode 100644 index 000000000..c65fcb33e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -0,0 +1,49 @@ +package org.openedx.course.presentation.unit.html + +import android.content.res.AssetManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.core.extension.readAsText +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 + +class HtmlUnitViewModel( + private val config: Config, + private val edxCookieManager: AppCookieManager, + private val networkConnection: NetworkConnection, + private val notifier: CourseNotifier +) : BaseViewModel() { + + private val _injectJSList = MutableStateFlow>(listOf()) + val injectJSList = _injectJSList.asStateFlow() + + val isOnline get() = networkConnection.isOnline() + val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() + val apiHostURL get() = config.getApiHostURL() + val cookieManager get() = edxCookieManager + + fun setWebPageLoaded(assets: AssetManager) { + if (_injectJSList.value.isNotEmpty()) return + + val jsList = mutableListOf() + + //Injection to intercept completion state for xBlocks + assets.readAsText("js_injection/completions.js")?.let { jsList.add(it) } + //Injection to fix CSS issues for Survey xBlock + assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } + + _injectJSList.value = jsList + } + + fun notifyCompletionSet() { + viewModelScope.launch { + notifier.send(CourseCompletionSet()) + } + } +}