fix(widget): avoid recycling aliased source bitmap for 2:3 covers (#4850)
Bitmap.createBitmap returns the same immutable instance when the center-crop covers the whole source, which happens for covers that decode to exactly 2:3. writeThumbnail then recycled that instance before createScaledBitmap used it, crashing with "cannot use a recycled source in createBitmap". Guard the recycle the same way the scaled vs cropped case is already guarded, and add an instrumented regression test. Also bundles a pending widget debugging note and a regenerated fastlane README that were staged in the working tree. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
package com.readest.native_bridge
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Instrumented test (runs on an Android device/emulator).
|
||||
*
|
||||
* Regression for the widget cover crash: a cover that decodes to exactly 2:3
|
||||
* makes the center-crop a no-op, so Bitmap.createBitmap returns the SAME
|
||||
* immutable instance as the source. The pre-fix code recycled the source right
|
||||
* after, then passed the now-recycled bitmap to createScaledBitmap, throwing
|
||||
* "cannot use a recycled source in createBitmap" and killing the app.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReadingWidgetStoreTest {
|
||||
@Test
|
||||
fun writeThumbnail_exact2to3Cover_doesNotCrash() {
|
||||
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
// 240x360 is exactly 2:3 and small enough to skip downsampling, so the
|
||||
// decoded cover hits the createBitmap same-instance path.
|
||||
val src = Bitmap.createBitmap(240, 360, Bitmap.Config.ARGB_8888)
|
||||
val srcFile = File(ctx.cacheDir, "widget-cover-2x3.png")
|
||||
srcFile.outputStream().use { src.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
src.recycle()
|
||||
|
||||
val hash = "regression2x3"
|
||||
try {
|
||||
// Pre-fix: throws IllegalArgumentException. Post-fix: writes the PNG.
|
||||
ReadingWidgetStore.writeThumbnail(ctx, hash, srcFile.absolutePath, 42)
|
||||
|
||||
val out = File(ReadingWidgetStore.coversDir(ctx), "$hash.png")
|
||||
assertTrue("thumbnail should be written", out.exists())
|
||||
val decoded = BitmapFactory.decodeFile(out.absolutePath)
|
||||
assertNotNull("thumbnail should decode to a valid bitmap", decoded)
|
||||
out.delete()
|
||||
} finally {
|
||||
srcFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -57,7 +57,11 @@ object ReadingWidgetStore {
|
||||
val cropX = (srcW - cropW) / 2
|
||||
val cropY = (srcH - cropH) / 2
|
||||
val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH)
|
||||
bitmap.recycle()
|
||||
// createBitmap returns the SAME instance when the crop covers the whole
|
||||
// (immutable) source — i.e. covers already at exactly 2:3. Recycling here
|
||||
// would recycle `cropped` too and crash createScaledBitmap below with
|
||||
// "cannot use a recycled source". Mirror the scaled !== cropped guard.
|
||||
if (cropped !== bitmap) bitmap.recycle()
|
||||
|
||||
// Scale the cropped bitmap to the target size.
|
||||
val scaled = Bitmap.createScaledBitmap(cropped, THUMB_WIDTH, THUMB_HEIGHT, true)
|
||||
|
||||
Reference in New Issue
Block a user