Skip to content

Commit

Permalink
fix: Download Exam Resources (#713)
Browse files Browse the repository at this point in the history
- In this commit, we added the option to download exam resources such as images and fonts.
- Once all questions are downloaded, we extract the resource URLs from the response, download the resources, and store them locally. After allresources are downloaded, we replace the network URLs with local URLs in the response and save it in the database.
- This will allow us to display images without an internet connection.
  • Loading branch information
PruthiviRaj27 authored Jul 20, 2024
1 parent 19f754c commit 42a8e12
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 16 deletions.
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?
)
1 change: 1 addition & 0 deletions 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
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
Expand Up @@ -8,4 +8,91 @@ 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 ->
urls.addAll(findUrls(html, urlPattern))
}
}

this.sections.forEach { section ->
section.instructions?.let { instructions ->
urls.addAll(findUrls(instructions, urlPattern))
}
}

this.questions.forEach { question ->
question.questionHtml?.let { questionHtml ->
urls.addAll(findUrls(questionHtml, urlPattern))
}
question.translations.forEach { translation ->
translation.questionHtml?.let { translationHtml ->
urls.addAll(findUrls(translationHtml, urlPattern))
}
}
question.answers.forEach { answer ->
answer.textHtml?.let { textHtml ->
urls.addAll(findUrls(textHtml, urlPattern))
}
}
}
}

return urls
}

private fun findUrls(html: String?, urlPattern: Regex): List<String> {
val urls = mutableListOf<String>()
html?.let {
urlPattern.findAll(it).forEach { matchResult ->
val url = matchResult.groupValues[1].trim()
urls.add(url)
}
}
return urls
}

fun replaceNetworkUrlWithLocalUrl(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.ResourceDownloader
import `in`.testpress.database.TestpressDatabase
import `in`.testpress.database.entities.*
import `in`.testpress.database.mapping.asGreenDaoModel
Expand All @@ -26,6 +27,7 @@ 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
Expand Down Expand Up @@ -55,6 +57,11 @@ 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
Expand Down Expand Up @@ -117,14 +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 Down Expand Up @@ -154,13 +160,41 @@ class OfflineExamRepository(val context: Context) {
return offlineAttemptDao.getOfflineAttemptsByCompleteState()
}

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()

ResourceDownloader(context).downloadResources(examResourcesUrl) { urlToLocalPaths ->
result.replaceNetworkUrlWithLocalUrl(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package `in`.testpress.course.util

import android.content.Context
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import okhttp3.OkHttpClient
import java.io.File
import java.io.FileOutputStream
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap


class ResourceDownloader(val context: Context) {
private val client = OkHttpClient()
private val semaphore = Semaphore(10)

suspend fun downloadResources(
urls: List<String>,
onComplete: suspend (HashMap<String, String>) -> Unit
) {
val urlToLocalPathMap = ConcurrentHashMap<String, String>()
coroutineScope {
val deferredDownloads = urls.map { url ->
async {
semaphore.withPermit {
downloadResource(url)?.let { localPath ->
urlToLocalPathMap[url] = localPath
}
}
}
}
deferredDownloads.awaitAll()
onComplete(HashMap(urlToLocalPathMap))
}
}

private fun downloadResource(url: String): String? {
return try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()

if (response.isSuccessful) {
response.body?.let { body ->
val fileName = url.substringAfterLast('/')
val file = File(context.filesDir, fileName)
val fos = FileOutputStream(file)
fos.use {
it.write(body.bytes())
}
body.close()
return "file://${file.absolutePath}"
}
}
response.close()
null
} catch (e: Exception) {
e.printStackTrace()
null
}
}

private suspend fun <T> Semaphore.withPermit(block: suspend () -> T): T {
acquire()
try {
return block()
} finally {
release()
}
}
}


0 comments on commit 42a8e12

Please sign in to comment.