Skip to content
Open
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
21 changes: 19 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -540,14 +552,19 @@ 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.
val withStrippedTags = stripHTMLScriptAndStyleTags(desc)
// #5188 - compat.fromHtml converts newlines into spaces.
val withoutWindowsLineEndings = withStrippedTags.replace("\r\n", "<br/>")
val withoutLinuxLineEndings = withoutWindowsLineEndings.replace("\n", "<br/>")
return HtmlCompat.fromHtml(withoutLinuxLineEndings, HtmlCompat.FROM_HTML_MODE_LEGACY)

return withoutLinuxLineEndings.parseAsHtml(
flags = HtmlCompat.FROM_HTML_MODE_LEGACY,
imageGetter = imageGetter,
)
}
}

Expand Down
182 changes: 182 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/ui/CollectionMediaImageGetter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Rakshita Chauhan <chauhanrakshita64@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.ui

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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 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)

override fun getDrawable(source: String): Drawable {
// Return a transparent placeholder
val wrapper = createWrapper()

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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe, otherwise

  • file Profile/collection.media2/file.txt
  • is listed as being inside Profile/collection.media
Suggested change
if (!localFile.canonicalPath.startsWith(mediaDir.canonicalPath)) {
if (!localFile.canonicalPath.startsWith(mediaDir.canonicalPath + + File.separator)) {

return@withContext null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timber.w here, definitely

}

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.Forest.w(e, "Failed to load deck description image: %s", source)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Timber.Forest.w(e, "Failed to load deck description image: %s", source)
Timber.w(e, "Failed to load deck description image: %s", source)

null
}
}

private fun updateWrapper(
wrapper: LevelListDrawable,
bitmap: Bitmap,
) {
val textView = containerRef.get()

if (textView == null) {
Timber.w("CollectionMediaImageGetter: TextView reference lost")
return
}
Comment on lines +103 to +108
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

90% sure this works

Suggested change
val textView = containerRef.get()
if (textView == null) {
Timber.w("CollectionMediaImageGetter: TextView reference lost")
return
}
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

textView.text = textView.text
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

}

companion object {
/**
* 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.
Comment on lines +134 to +161
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider moving these to BitmapUtil

*/
@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
}
}
}