-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(study-options): load local images in deck description #19912
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) { | ||||||||||||||||||||||
| return@withContext null | ||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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) | ||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 90% sure this works
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. consider moving these to |
||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| @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 | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe, otherwise
Profile/collection.media2/file.txtProfile/collection.media