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 + } }