Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: reviewer fetch and XMLHttpRequest issues #16457

Merged
merged 2 commits into from
May 23, 2024
Merged
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
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/assets/scripts/js-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class AnkiDroidJS {
}

handleRequest = async (endpoint, data) => {
const url = `${ankidroid.postBaseUrl}jsapi/${endpoint}`;
const url = `/jsapi/${endpoint}`;
try {
const response = await fetch(url, {
method: "POST",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,6 @@ abstract class AbstractFlashcardViewer :
@get:VisibleForTesting
var cardContent: String? = null
private set
private val baseUrl
get() = getMediaBaseUrl(CollectionHelper.getMediaDirectory(this).path)

private var viewerUrl: String? = null
private val fadeDuration = 300
Expand Down Expand Up @@ -1502,7 +1500,7 @@ abstract class AbstractFlashcardViewer :
if (card != null) {
card.settings.mediaPlaybackRequiresUserGesture = !cardMediaPlayer.config.autoplay
card.loadDataWithBaseURL(
baseUrl,
server.baseUrl(),
content,
"text/html",
null,
Expand Down Expand Up @@ -2265,9 +2263,6 @@ abstract class AbstractFlashcardViewer :
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
pageRenderStopwatch.reset()
pageFinishedFired = false
val script = "globalThis.ankidroid = globalThis.ankidroid || {};" +
"ankidroid.postBaseUrl = `${server.baseUrl()}`"
view?.evaluateJavascript(script, null)
}

override fun shouldInterceptRequest(
Expand Down
87 changes: 70 additions & 17 deletions AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
* Copyright (c) 2024 Brayan Oliveira <[email protected]>
* Copyright (c) 2023 Brayan Oliveira <[email protected]>
* Copyright (c) 2023 David Allison <[email protected]>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
Expand All @@ -18,37 +19,89 @@ package com.ichi2.anki
import android.content.Context
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import androidx.core.net.toFile
import com.ichi2.utils.AssetHelper.guessMimeType
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.RandomAccessFile
import java.nio.channels.Channels

private const val RANGE_HEADER = "Range"

class ViewerResourceHandler(context: Context) {
private val mediaDir = CollectionHelper.getMediaDirectory(context).path

/**
* Loads resources from `collection.media` when requested by JS scripts.
*
* Differently from common media requests, scripts' requests have an `Origin` header
* and are susceptible to CORS policy, so `Access-Control-Allow-Origin` is necessary.
*/
private val mediaDir = CollectionHelper.getMediaDirectory(context)

fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
val url = request.url
if (request.method != "GET" || url.scheme != "file" || "Origin" !in request.requestHeaders) {
val path = url.path

if (request.method != "GET" || path == null) {
return null
}
if (path == "/favicon.ico") {
return WebResourceResponse(null, null, ByteArrayInputStream(ByteArray(0)))
}

try {
val file = url.toFile()
if (file.parent != mediaDir || !file.exists()) {
val file = File(mediaDir, path)
if (!file.exists()) {
return null
}
val inputStream = FileInputStream(file)
return WebResourceResponse(guessMimeType(file.path), null, inputStream).apply {
responseHeaders = mapOf("Access-Control-Allow-Origin" to "*")
request.requestHeaders[RANGE_HEADER]?.let { range ->
return handlePartialContent(file, range)
}
val inputStream = FileInputStream(file)
val mimeType = guessMimeType(path)
return WebResourceResponse(mimeType, null, inputStream)
} catch (e: Exception) {
Timber.d("File couldn't be loaded")
Timber.d("File not found")
return null
}
}

private fun handlePartialContent(file: File, range: String): WebResourceResponse {
val rangeHeader = RangeHeader.from(range, defaultEnd = file.length() - 1)

val mimeType = guessMimeType(file.path)
val inputStream = file.toInputStream(rangeHeader)
val (start, end) = rangeHeader
val responseHeaders = mapOf(
"Content-Range" to "bytes $start-$end/${file.length()}",
"Accept-Range" to "bytes"
)
return WebResourceResponse(
mimeType,
null,
206,
"Partial Content",
responseHeaders,
inputStream
)
}
}

/**
* Handles the "range" header in a HTTP Request
*/
data class RangeHeader(val start: Long, val end: Long) {
companion object {
fun from(range: String, defaultEnd: Long): RangeHeader {
val numbers = range.substring("bytes=".length).split('-')
val unspecifiedEnd = numbers.getOrNull(1).isNullOrEmpty()
return RangeHeader(
start = numbers[0].toLong(),
end = if (unspecifiedEnd) defaultEnd else numbers[1].toLong()
)
}
}
}

fun File.toInputStream(header: RangeHeader): InputStream {
// PERF: Test to see if a custom FileInputStream + available() would be faster
val randomAccessFile = RandomAccessFile(this, "r")
return Channels.newInputStream(randomAccessFile.channel).also {
it.skip(header.start)
}
}
6 changes: 0 additions & 6 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ open class AnkiServer(
Timber.d("Rejecting GET request to server %s", session.uri)
newFixedLengthResponse(Response.Status.NOT_FOUND, null, null)
}
Method.OPTIONS -> buildResponse(null)
else -> {
Timber.d("Ignored request of unhandled method %s, uri %s", session.method, session.uri)
newFixedLengthResponse(null)
Expand All @@ -70,11 +69,6 @@ open class AnkiServer(
newFixedLengthResponse(null)
} else {
newChunkedResponse(status, mimeType, ByteArrayInputStream(data))
}.apply {
addHeader("Access-Control-Allow-Origin", "*")
addHeader("Access-Control-Allow-Headers", "Content-Type")
addHeader("Access-Control-Allow-Methods", "POST")
addHeader("Access-Control-Max-Age", "7200")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.ichi2.anki.CollectionHelper
import com.ichi2.anki.R
import com.ichi2.anki.ViewerResourceHandler
import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
import com.ichi2.anki.localizedErrorMessage
import com.ichi2.anki.pages.AnkiServer
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ext.packageManager
import com.ichi2.compat.CompatHelper.Companion.resolveActivityCompat
Expand Down Expand Up @@ -87,9 +87,9 @@ abstract class CardViewerFragment(@LayoutRes layout: Int) : Fragment(layout) {
// allow videos to autoplay via our JavaScript eval
mediaPlaybackRequiresUserGesture = false
}
val baseUrl = CollectionHelper.getMediaDirectory(requireContext()).toURI().toString()

loadDataWithBaseURL(
baseUrl,
"http://${AnkiServer.LOCALHOST}/",
stdHtml(requireContext(), Themes.currentTheme.isNightMode),
"text/html",
null,
Expand Down
Loading