Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package `in`.testpress.database.dao

import `in`.testpress.database.BaseDao
import `in`.testpress.database.entities.OfflineAttempt
import `in`.testpress.models.greendao.Attempt
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
Expand Down Expand Up @@ -32,4 +34,7 @@ interface OfflineAttemptDao: BaseDao<OfflineAttempt>{

@Query("SELECT id FROM OfflineAttempt WHERE examId = :examId")
suspend fun getAttemptIdsByExamId(examId: Long): List<Long>

@Query("SELECT * FROM OfflineAttempt WHERE state = :state")
fun getOfflineAttemptsByCompleteState(state: String = Attempt.COMPLETED): LiveData<List<OfflineAttempt>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ package `in`.testpress.database.entities

data class Answer(
val id: Long? = null,
val textHtml: String? = null,
var textHtml: String? = null,
val saveId: Long? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import androidx.room.PrimaryKey
data class Direction(
@PrimaryKey
val id: Long? = null,
val html: String? = null
var html: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import androidx.room.PrimaryKey
data class Question(
@PrimaryKey
val id: Long? = null,
val questionHtml: String? = null,
var questionHtml: String? = null,
val directionId: Long? = null,
val answers: ArrayList<Answer> = arrayListOf(),
val language: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ data class Section(
val name: String?,
val duration: String?,
val cutOff: Long?,
val instructions: String?,
var instructions: String?,
val parent: Long?
)
9 changes: 8 additions & 1 deletion core/src/main/java/in/testpress/util/WebViewUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public static void initWebView(WebView webView) {
webSettings.setLoadWithOverviewMode(true);
webSettings.setDatabaseEnabled(true);
webSettings.setDomStorageEnabled(true);
webSettings.setAllowFileAccess(true);
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
webSettings.setRenderPriority(WebSettings.RenderPriority.HIGH);
}
Expand Down Expand Up @@ -92,7 +93,9 @@ public void onReceivedError(WebView view, WebResourceRequest request,
return;
}
onNetworkError();
hasError = true;
if (!isOfflineExamMode()){
hasError = true;
}
}
});
loadHtml(htmlContent);
Expand Down Expand Up @@ -251,6 +254,10 @@ public String getHeader() {
return getBaseHeader();
}

public boolean isOfflineExamMode() {
return false;
}

public String getQuestionsHeader() {
return getBaseHeader() +
"<link rel='stylesheet' type='text/css' href='testpress_questions_typebase.css' />" +
Expand Down
6 changes: 5 additions & 1 deletion core/src/main/java/in/testpress/util/extension/String.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
package `in`.testpress.util.extension

fun String?.isNotNullAndNotEmpty() = this != null && this.isNotEmpty()
fun String?.isNotNullAndNotEmpty() = this != null && this.isNotEmpty()

fun List<String>.validateHttpAndHttpsUrls(): List<String> {
return this.filter { it.startsWith("http://") || it.startsWith("https://") }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >

<corners android:radius="30dp" />

<solid android:color="@color/testpress_color_primary" />

</shape>
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

const val isOfflineExamSupportEnables = false
const val isOfflineExamSupportEnables = true

open class BaseExamWidgetFragment : Fragment() {
lateinit var startButton: Button
Expand Down Expand Up @@ -141,6 +141,21 @@ open class BaseExamWidgetFragment : Fragment() {
else -> {}
}
}
offlineExamViewModel.syncCompletedAttempt(content.examId!!)
offlineExamViewModel.syncCompletedAttempt.observe(requireActivity()) { it ->
when (it.status){
Status.SUCCESS -> {
if (!this.isAdded) return@observe
Toast.makeText(requireContext(),"Answers submitted successfully. Results will be available shortly.",Toast.LENGTH_SHORT).show()
}
Status.LOADING -> {}
Status.ERROR -> {
if (!this.isAdded) return@observe
Toast.makeText(requireContext(),"Please connect to the internet to view your results.",Toast.LENGTH_SHORT).show()
}
else -> {}
}
}
}

private fun showOfflineExamButtons() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,95 @@ class NetworkOfflineQuestionResponse(
val sections: List<Section>,
val examQuestions: List<ExamQuestion>,
val questions: List<Question>
)
){
fun extractUrls(): List<String> {
val regexs = listOf(Regex("""src=["'](.*?)["']"""), Regex("""(?:url|src)\((.*?)\)"""))
val urls = mutableListOf<String>()

regexs.forEach { urlPattern ->
this.directions.forEach { direction ->
direction.html?.let { html ->
urlPattern.findAll(html).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
}

this.sections.forEach { section ->
section.instructions?.let { instructions ->
urlPattern.findAll(instructions).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
}

this.questions.forEach { question ->
question.questionHtml?.let { questionHtml ->
urlPattern.findAll(questionHtml).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
question.translations.forEach { translation ->
translation.questionHtml?.let { translationHtml ->
urlPattern.findAll(translationHtml).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
}
question.answers.forEach { answer ->
answer.textHtml?.let { textHtml ->
urlPattern.findAll(textHtml).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
}
}
}

return urls
}

fun replaceResourceUrlWithLocalUrl(urlToLocalPaths: HashMap<String, String>) {

urlToLocalPaths.map { urlToLocalPath ->

this.directions.forEach { direction ->
direction.html?.let { directionHtml ->
direction.html = directionHtml.replace(urlToLocalPath.key, urlToLocalPath.value)
}
}

this.sections.forEach { section ->
section.instructions?.let { instructions ->
section.instructions =
instructions.replace(urlToLocalPath.key, urlToLocalPath.value)
}
}

this.questions.forEach { question ->
question.questionHtml?.let { questionHtml ->
question.questionHtml =
questionHtml.replace(urlToLocalPath.key, urlToLocalPath.value)
}

question.translations.forEach { translation ->
translation.questionHtml?.let { translationHtml ->
translation.questionHtml =
translationHtml.replace(urlToLocalPath.key, urlToLocalPath.value)
}
}

question.answers.forEach { answer ->
answer.textHtml?.let { textHtml ->
answer.textHtml = textHtml.replace(urlToLocalPath.key, urlToLocalPath.value)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import `in`.testpress.course.network.CourseNetwork
import `in`.testpress.course.network.NetworkContent
import `in`.testpress.course.network.NetworkOfflineQuestionResponse
import `in`.testpress.course.network.asOfflineExam
import `in`.testpress.course.util.ResourcesDownloader
import `in`.testpress.database.TestpressDatabase
import `in`.testpress.database.entities.*
import `in`.testpress.database.mapping.asGreenDaoModel
Expand All @@ -26,12 +27,14 @@ import `in`.testpress.network.RetrofitCall
import `in`.testpress.util.PagedApiFetcher
import `in`.testpress.util.extension.isNotNull
import `in`.testpress.util.extension.isNotNullAndNotEmpty
import `in`.testpress.util.extension.validateHttpAndHttpsUrls
import `in`.testpress.v2_4.models.ApiResponse
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import java.time.ZoneId
import java.time.ZonedDateTime
Expand All @@ -54,10 +57,18 @@ class OfflineExamRepository(val context: Context) {
private val offlineCourseAttemptDao = database.offlineCourseAttemptDao()
private val offlineAttemptSectionDao = database.offlineAttemptSectionDao()
private val offlineAttemptItemDao = database.offlineAttemptItemDoa()
private val directions = mutableListOf<Direction>()
private val subjects = mutableListOf<Subject>()
private val sections = mutableListOf<Section>()
private val examQuestions = mutableListOf<ExamQuestion>()
private val questions = mutableListOf<Question>()

private val _downloadExamResult = MutableLiveData<Resource<Boolean>>()
val downloadExamResult: LiveData<Resource<Boolean>> get() = _downloadExamResult

private val _syncCompletedAttempt = MutableLiveData<Resource<Boolean>>()
val syncCompletedAttempt: LiveData<Resource<Boolean>> get() = _syncCompletedAttempt

fun downloadExam(contentId: Long) {
_downloadExamResult.postValue(Resource.loading(null))
courseClient.getNetworkContentWithId(contentId)
Expand Down Expand Up @@ -113,15 +124,13 @@ class OfflineExamRepository(val context: Context) {
.enqueue(object : TestpressCallback<ApiResponse<NetworkOfflineQuestionResponse>>() {
override fun onSuccess(result: ApiResponse<NetworkOfflineQuestionResponse>) {
if (result.next != null) {
saveQuestionsToDB(result.results)
handleSuccessResponse(examId, result.results)
updateOfflineExamDownloadPercent(examId, result.results!!.questions.size.toLong())
page++
fetchQuestionsPage()
} else {
saveQuestionsToDB(result.results)
handleSuccessResponse(examId, result.results, lastPage = true)
updateOfflineExamDownloadPercent(examId, result.results!!.questions.size.toLong())
updateDownloadedState(examId)
_downloadExamResult.postValue(Resource.success(true))
}
}

Expand All @@ -146,13 +155,41 @@ class OfflineExamRepository(val context: Context) {
}
}

private fun saveQuestionsToDB(response: NetworkOfflineQuestionResponse){
CoroutineScope(Dispatchers.IO).launch {
directionDao.insertAll(response.directions)
subjectDao.insertAll(response.subjects)
sectionsDao.insertAll(response.sections)
examQuestionDao.insertAll(response.examQuestions)
questionDao.insertAll(response.questions)
private fun handleSuccessResponse(
examId: Long,
response: NetworkOfflineQuestionResponse,
lastPage: Boolean = false
) {
directions.addAll(response.directions)
subjects.addAll(response.subjects)
sections.addAll(response.sections)
examQuestions.addAll(response.examQuestions)
questions.addAll(response.questions)

if (lastPage) {
CoroutineScope(Dispatchers.IO).launch {
val result = NetworkOfflineQuestionResponse(
directions,
subjects,
sections,
examQuestions,
questions,
)

val examResourcesUrl =
result.extractUrls().toSet().toList().validateHttpAndHttpsUrls()

ResourcesDownloader(context).downloadResources(examResourcesUrl) { urlToLocalPaths ->
result.replaceResourceUrlWithLocalUrl(urlToLocalPaths)
directionDao.insertAll(result.directions)
subjectDao.insertAll(result.subjects)
sectionsDao.insertAll(result.sections)
examQuestionDao.insertAll(result.examQuestions)
questionDao.insertAll(result.questions)
updateDownloadedState(examId)
_downloadExamResult.postValue(Resource.success(true))
}
}
}
}

Expand Down Expand Up @@ -250,10 +287,25 @@ class OfflineExamRepository(val context: Context) {
return offlineAttemptSectionDao.getByAttemptId(attemptId)
}

suspend fun syncCompletedAttemptToBackEnd() {
suspend fun syncCompletedAttempt(examId: Long){
val completedOfflineAttempts = offlineAttemptDao.getOfflineAttemptsByExamIdAndState(examId, Attempt.COMPLETED)
updateCompletedAttempts(completedOfflineAttempts)
}

suspend fun syncCompletedAllAttemptToBackEnd() {
val completedOfflineAttempts =
offlineAttemptDao.getOfflineAttemptsByState(Attempt.COMPLETED)
updateCompletedAttempts(completedOfflineAttempts)
}

fun getOfflineAttemptsByCompleteState() :LiveData<List<OfflineAttempt>> {
return offlineAttemptDao.getOfflineAttemptsByCompleteState()
}

private suspend fun updateCompletedAttempts(completedOfflineAttempts: List<OfflineAttempt>){
if(completedOfflineAttempts.isEmpty()) return
val totalAttempts = completedOfflineAttempts.size
var currentAttemptSize = 0
completedOfflineAttempts.forEach { completedOfflineAttempt ->

val attemptItems =
Expand Down Expand Up @@ -288,11 +340,19 @@ class OfflineExamRepository(val context: Context) {
override fun onSuccess(result: HashMap<String, String>) {
if (result["message"] == "Exam answers are being processed") {
deleteSyncedAttempt(completedOfflineAttempt.id)
currentAttemptSize++
if (totalAttempts == currentAttemptSize){
_syncCompletedAttempt.postValue(Resource.success(true))
}
}
}

override fun onException(exception: TestpressException?) {
override fun onException(exception: TestpressException) {
Log.e("OfflineExamRepository", "Failed to update offline answers", exception)
currentAttemptSize++
if (totalAttempts == currentAttemptSize){
_syncCompletedAttempt.postValue(Resource.error(exception, null))
}
}
})
}
Expand Down
Loading