From 552e1b1e0c6a05cce09361a80f46a4c25a00c596 Mon Sep 17 00:00:00 2001 From: Arthur Milchior Date: Sun, 25 May 2025 02:41:29 +0200 Subject: [PATCH 1/2] NF: Rewrite `shouldInterceptRequest` I believe that using thit `when` makes the various case. --- .../com/ichi2/anki/ViewerResourceHandler.kt | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt index a573b5d3fcf8..675f0d213819 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt @@ -38,18 +38,17 @@ class ViewerResourceHandler( private val mediaDir = CollectionHelper.getMediaDirectory(context) fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { - val url = request.url - val path = url.path - - if (request.method != "GET" || path == null) { - return null - } - if (path == "/favicon.ico") { - return WebResourceResponse(null, null, ByteArrayInputStream(ByteArray(0))) - } - - try { - if (path.startsWith(MATHJAX_PATH_PREFIX)) { + val path = request.url.path ?: return null + val range = request.requestHeaders[RANGE_HEADER] + return when { + request.method != "GET" -> null + path == "/favicon.ico" -> + WebResourceResponse( + null, + null, + ByteArrayInputStream(ByteArray(0)), + ) + path.startsWith(MATHJAX_PATH_PREFIX) -> { val mathjaxAssetPath = Paths .get( @@ -57,25 +56,40 @@ class ViewerResourceHandler( path.removePrefix(MATHJAX_PATH_PREFIX), ).pathString val inputStream = assetManager.open(mathjaxAssetPath) - return WebResourceResponse(guessMimeType(path), null, inputStream) + try { + WebResourceResponse(guessMimeType(path), null, inputStream) + } catch (_: Exception) { + Timber.d("File $mathjaxAssetPath not found") + null + } } - - val file = File(mediaDir, path) - if (!file.exists()) { - return null + range != null -> { + handlePartialContent(file(path) ?: return null, range) } - request.requestHeaders[RANGE_HEADER]?.let { range -> - return handlePartialContent(file, range) + else -> { + try { + val inputStream = FileInputStream(file(path) ?: return null) + val mimeType = guessMimeType(path) + return WebResourceResponse(mimeType, null, inputStream) + } catch (_: Exception) { + Timber.d("File $path not found") + return null + } } - val inputStream = FileInputStream(file) - val mimeType = guessMimeType(path) - return WebResourceResponse(mimeType, null, inputStream) - } catch (e: Exception) { - Timber.d("File not found") - return null } } + /** + * Returns the file at path if it exists, + */ + private fun file(path: String) = + try { + File(mediaDir, path).takeIf { it.exists() } + } catch (_: Exception) { + Timber.d("can't check whether $path exists.") + null + } + @NeedsTest("seeking audio - 16513") private fun handlePartialContent( file: File, From 8d0fefbd851a5379ea8c591b7ddce72b9a604250 Mon Sep 17 00:00:00 2001 From: Arthur Milchior Date: Sun, 25 May 2025 03:59:27 +0200 Subject: [PATCH 2/2] Prefetch 20 media which are at most 10mb to prepare the answer side Sometime, the answer side's image are slow to load, and I find it frustrating as the images were already on the front side. I'm not sure there is a good way to prepare the answer side webview because of the javascript part, but we certainly can prepare the media. This is what I do in a currently very naive heuristic. That is, I search for `src="..."`, and if the part inside the quote corresponds to a media that exists, and it weigths at most 10mb, I prefetch it so that there is no need for file access later. Only the first 20 media are fetched, in order to limit the memory impact. I expect that: * most cards would actually contains less than 20 media and most media of less than 10mb, so probably would still improve most cards, * the memory usage is probably reasonable as it's already the usage in the webview. If this works well and don't cause any issue, I'd love to do the same thing for the question side. But I'd need a back-end method that allows to fetch the potential future question. So I want to experiment with answer side first. Also, when the same media appear in both side, it may be interesting to avoid reading the file twice and instead saving the file during the first read. This improvement would make sense to do if this experiment is a success. If so, doing the same thing for audio can be interesting too. --- .../com/ichi2/anki/AbstractFlashcardViewer.kt | 10 +- .../com/ichi2/anki/ViewerResourceHandler.kt | 96 ++++++++++++++++--- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 8a88907d265d..e628875586cf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -535,6 +535,7 @@ abstract class AbstractFlashcardViewer : // set the correct mark/unmark icon on action bar refreshActionBar() focusDefaultLayout() + prefetchAnswerMedia() } private fun focusDefaultLayout() { @@ -1301,6 +1302,12 @@ abstract class AbstractFlashcardViewer : } } + private suspend fun prefetchAnswerMedia() { + withCol { currentCard?.answer(this) }?.let { html -> + ViewerResourceHandler.prefetch(baseContext, html) + } + } + open fun displayCardQuestion() { Timber.d("displayCardQuestion()") displayAnswer = false @@ -1315,7 +1322,8 @@ abstract class AbstractFlashcardViewer : } else { answerField?.visibility = View.GONE } - val content = cardRenderContext!!.renderCard(getColUnsafe, currentCard!!, SingleCardSide.FRONT) + val col = getColUnsafe + val content = cardRenderContext!!.renderCard(col, currentCard!!, SingleCardSide.FRONT) automaticAnswer.onDisplayQuestion() launchCatchingTask { if (!automaticAnswerShouldWaitForAudio()) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt index 675f0d213819..3b2a467ba861 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt @@ -25,17 +25,20 @@ import timber.log.Timber import java.io.ByteArrayInputStream import java.io.File import java.io.FileInputStream +import java.io.InputStream import java.nio.file.Paths +import java.util.regex.Pattern import kotlin.io.path.pathString +import kotlin.text.get private const val RANGE_HEADER = "Range" private const val MATHJAX_PATH_PREFIX = "/_anki/js/vendor/mathjax" +private val srcPattern = Pattern.compile("src=\"([^\"]*)\"") class ViewerResourceHandler( - context: Context, + private val context: Context, ) { private val assetManager = context.assets - private val mediaDir = CollectionHelper.getMediaDirectory(context) fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { val path = request.url.path ?: return null @@ -48,6 +51,7 @@ class ViewerResourceHandler( null, ByteArrayInputStream(ByteArray(0)), ) + path.startsWith(MATHJAX_PATH_PREFIX) -> { val mathjaxAssetPath = Paths @@ -64,11 +68,11 @@ class ViewerResourceHandler( } } range != null -> { - handlePartialContent(file(path) ?: return null, range) + handlePartialContent(file(context, path) ?: return null, range) } else -> { try { - val inputStream = FileInputStream(file(path) ?: return null) + val inputStream = inputStream(context, path) ?: return null val mimeType = guessMimeType(path) return WebResourceResponse(mimeType, null, inputStream) } catch (_: Exception) { @@ -79,17 +83,6 @@ class ViewerResourceHandler( } } - /** - * Returns the file at path if it exists, - */ - private fun file(path: String) = - try { - File(mediaDir, path).takeIf { it.exists() } - } catch (_: Exception) { - Timber.d("can't check whether $path exists.") - null - } - @NeedsTest("seeking audio - 16513") private fun handlePartialContent( file: File, @@ -126,6 +119,79 @@ class ViewerResourceHandler( fileStream, ) } + + companion object { + /** + * Returns the file at path if it exists, + */ + private fun file( + context: Context, + path: String, + ): File? { + val mediaDir = CollectionHelper.getMediaDirectory(context) + return try { + File(mediaDir, path).takeIf { it.exists() } + } catch (_: Exception) { + Timber.d("can't check whether $path exists.") + null + } + } + + private fun inputStream( + context: Context, + path: String, + ): InputStream? = getByteArray(path)?.let { ByteArrayInputStream(it) } ?: file(context, path)?.let { FileInputStream(it) } + + /** + * Associate to file name the byte array of this file. + */ + private val prefetch = mutableMapOf() + + fun getByteArray(path: String): ByteArray? = prefetch[path] + + private fun findSrcs(html: String): Iterable { + val paths = mutableSetOf() + val m = srcPattern.matcher(html) + while (m.find()) { + paths.add(m.group(1)!!) + } + return paths + } + + fun prefetch( + context: Context, + html: String, + ) { + data class PrefetchData( + val length: Int, + val stream: FileInputStream, + val path: String, + ) + val srcs = findSrcs(html) + val inputStreams = + srcs.mapNotNull { path -> + val file = file(context, path) ?: return@mapNotNull null + val length = + try { + file.length() + } catch (_: Exception) { + Timber.d("File $path exists but its length can't be determined.") + return@mapNotNull null + } + if (length > 10 * 1024 * 1024) { + Timber.d("File $path length is $length, greater than 10 mb, not caching it") + return@mapNotNull null + } + return@mapNotNull PrefetchData(length.toInt(), FileInputStream(file), path) + } + prefetch.clear() + for (data in inputStreams.take(20)) { + val bytes = ByteArray(data.length) + data.stream.read(bytes) + prefetch[data.path] = bytes + } + } + } } /**