diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
index 7455b7fcb45f..4d78ef86e9aa 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
@@ -16,6 +16,7 @@ package com.ichi2.anki
import android.app.Activity
import android.content.Intent
import android.os.Bundle
+import android.text.Html
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@@ -32,6 +33,7 @@ import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.Group
import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
+import androidx.core.text.parseAsHtml
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
@@ -51,6 +53,7 @@ import com.ichi2.anki.reviewreminders.ScheduleReminders
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.ui.internationalization.toSentenceCase
import com.ichi2.anki.utils.ext.showDialogFragment
+import com.ichi2.ui.CollectionMediaImageGetter
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -459,7 +462,16 @@ class StudyOptionsFragment :
}
}
if (desc.isNotEmpty()) {
- textDeckDescription.text = formatDescription(desc)
+ val mediaDir = col.media.dir
+ val imageGetter =
+ CollectionMediaImageGetter(
+ requireContext(),
+ textDeckDescription,
+ mediaDir,
+ viewLifecycleOwner.lifecycleScope,
+ )
+
+ textDeckDescription.text = formatDescription(desc, imageGetter)
textDeckDescription.visibility = View.VISIBLE
} else {
textDeckDescription.visibility = View.GONE
@@ -540,6 +552,7 @@ class StudyOptionsFragment :
@VisibleForTesting
fun formatDescription(
@Language("HTML") desc: String,
+ imageGetter: Html.ImageGetter? = null,
): Spanned {
// #5715: In deck description, ignore what is in style and script tag
// Since we don't currently execute the JS/CSS, it's not worth displaying.
@@ -547,7 +560,11 @@ class StudyOptionsFragment :
// #5188 - compat.fromHtml converts newlines into spaces.
val withoutWindowsLineEndings = withStrippedTags.replace("\r\n", "
")
val withoutLinuxLineEndings = withoutWindowsLineEndings.replace("\n", "
")
- return HtmlCompat.fromHtml(withoutLinuxLineEndings, HtmlCompat.FROM_HTML_MODE_LEGACY)
+
+ return withoutLinuxLineEndings.parseAsHtml(
+ flags = HtmlCompat.FROM_HTML_MODE_LEGACY,
+ imageGetter = imageGetter,
+ )
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/CollectionMediaImageGetter.kt b/AnkiDroid/src/main/java/com/ichi2/ui/CollectionMediaImageGetter.kt
new file mode 100644
index 000000000000..77807bd26c1c
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/ui/CollectionMediaImageGetter.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2025 Rakshita Chauhan
+ *
+ * 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
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.ui
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.LevelListDrawable
+import android.text.Html
+import android.widget.TextView
+import androidx.annotation.CheckResult
+import androidx.core.graphics.drawable.toDrawable
+import com.ichi2.utils.BitmapUtil.decodeSampledBitmap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.File
+import java.lang.ref.WeakReference
+
+/**
+ * Async ImageGetter for Deck Descriptions.
+ * Resolves local images from the collection media directory and downsamples them to prevent OOM.
+ */
+class CollectionMediaImageGetter(
+ private val context: Context,
+ view: TextView,
+ private val mediaDir: File,
+ private val scope: CoroutineScope,
+) : Html.ImageGetter {
+ private val containerRef = WeakReference(view)
+ private var imageCount = 0
+
+ override fun getDrawable(source: String): Drawable {
+ // Return a transparent placeholder
+ val wrapper = createWrapper()
+
+ // limit the number of images loaded to prevent ANRs/OOMs
+ if (imageCount >= MAX_IMAGE_COUNT) {
+ return wrapper
+ }
+ imageCount++
+
+ scope.launch {
+ val bitmap = loadBitmap(source) ?: return@launch
+ updateWrapper(wrapper, bitmap)
+ }
+
+ return wrapper
+ }
+
+ private fun createWrapper(): LevelListDrawable {
+ val wrapper = LevelListDrawable()
+ val empty = Color.TRANSPARENT.toDrawable()
+ wrapper.addLevel(0, 0, empty)
+ wrapper.setBounds(0, 0, 0, 0)
+ return wrapper
+ }
+
+ @CheckResult
+ private suspend fun loadBitmap(source: String): Bitmap? =
+ withContext(Dispatchers.IO) {
+ try {
+ // Skip remote images
+ if (source.startsWith("http://") || source.startsWith("https://")) {
+ return@withContext null
+ }
+
+ val localFile = File(mediaDir, source)
+
+ // Prevent path traversal to ensure file is inside media directory
+ if (!localFile.canonicalPath.startsWith(mediaDir.canonicalPath + File.separator)) {
+ Timber.w("CollectionMediaImageGetter: Path traversal attempt detected: %s", source)
+ return@withContext null
+ }
+
+ if (localFile.exists()) {
+ // Use view width or fallback to screen width
+ val reqWidth =
+ (containerRef.get()?.width ?: 0)
+ .coerceAtLeast(context.resources.displayMetrics.widthPixels)
+ decodeSampledBitmap(localFile, reqWidth)
+ } else {
+ null
+ }
+ } catch (e: Throwable) {
+ Timber.w(e, "Failed to load deck description image: %s", source)
+ null
+ }
+ }
+
+ private fun updateWrapper(
+ wrapper: LevelListDrawable,
+ bitmap: Bitmap,
+ ) {
+ val textView =
+ containerRef.get() ?: run {
+ Timber.w("CollectionMediaImageGetter: TextView reference lost")
+ return
+ }
+
+ val d = bitmap.toDrawable(context.resources)
+
+ var viewWidth = textView.width
+ if (viewWidth <= 0) viewWidth = context.resources.displayMetrics.widthPixels
+
+ val scale = viewWidth.toFloat() / bitmap.width
+ var w = bitmap.width
+ var h = bitmap.height
+
+ // Downscale to fit view if necessary
+ if (scale < 1.0f) {
+ w = viewWidth
+ h = (h * scale).toInt()
+ }
+
+ d.setBounds(0, 0, w, h)
+ wrapper.addLevel(1, 1, d)
+ wrapper.setBounds(0, 0, w, h)
+ wrapper.level = 1
+
+ // Force a re-layout to fit the new image dimensions
+ textView.text = textView.text
+ }
+
+ companion object {
+ private const val MAX_IMAGE_COUNT = 10
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt
index b075cf6076c6..9cba3846ec48 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/BitmapUtil.kt
@@ -23,6 +23,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import android.widget.ImageView
+import androidx.annotation.CheckResult
import com.ichi2.anki.CrashReportService
import timber.log.Timber
import java.io.File
@@ -90,4 +91,52 @@ object BitmapUtil {
Timber.e(e)
}
}
+
+ /**
+ * Decodes a file, downsampling it to fit within the reqWidth.
+ */
+ @CheckResult
+ fun decodeSampledBitmap(
+ file: File,
+ reqWidth: Int,
+ ): Bitmap? {
+ // First decode with inJustDecodeBounds=true to check dimensions
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeFile(file.absolutePath, options)
+
+ // Calculate inSampleSize
+ options.inSampleSize = calculateInSampleSize(options, reqWidth)
+
+ // Decode bitmap with inSampleSize set
+ options.inJustDecodeBounds = false
+ return BitmapFactory.decodeFile(file.absolutePath, options)
+ }
+
+ /**
+ * Calculate the largest inSampleSize value that is a power of 2 and keeps
+ * width larger than the requested width.
+ *
+ * @param options The Options object, which must have been populated by a previous
+ * decoding call with inJustDecodeBounds=true.
+ * @param reqWidth The target width to fit the image into.
+ */
+ @CheckResult
+ fun calculateInSampleSize(
+ options: BitmapFactory.Options,
+ reqWidth: Int,
+ ): Int {
+ // Raw width of image
+ val width = options.outWidth
+ var inSampleSize = 1
+
+ if (width <= reqWidth) {
+ return 1
+ }
+ val halfWidth = width / 2
+ while ((halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2
+ }
+ return inSampleSize
+ }
}