[Android] Button/glyph icons crash with "Canvas: trying to use a recycled bitmap" — MauiCustomTarget.onLoadCleared never releases the drawable (PR #12310 only fixed the ImageView path)
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 297
Description
### Description
### Description
`Canvas: trying to use a recycled bitmap` still crashes on Android for image sources
rendered on **non-ImageView** consumers, most commonly `Button.ImageSource` with a
`FontImageSource`. The well-known family of these crashes (#9007, #8178, #8809, #9011,
#9712, #10032, #11130, #11519, #13182, #13534, #18834) was closed by PR #12310, but that
PR changed **only** `MauiCustomViewTarget.java` — the `ImageView` path. The
`CustomTarget` path that every non-ImageView consumer uses was never given the
equivalent handling, and it still crashes today on 10.0.80.
The two paths diverge in `PlatformInterop.java`:
- `loadInto(...)` → `MauiCustomViewTarget extends CustomViewTarget`
→ `onResourceCleared` calls `this.view.setImageDrawable(placeholder)`, so the view drops
its reference when Glide reclaims the bitmap. This is the #12310 fix.
- `load(...)` → `MauiCustomTarget extends CustomTarget` → `onLoadCleared` only
writes a verbose log line:
~~~java
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
if (logger.isVerboseLoggable) logger.v("onLoadCleared: " + resourceLogIdentifier);
}
~~~
So when Glide clears the request — the `RequestManager` for an `Activity` context is
cleared on `Activity.onDestroy`, and the C# `ImageSourceServiceResult` dispose path also
invokes `MauiCustomTarget.clear()` — the bitmap returns to Glide's pool while the
consumer still holds the `Drawable`. The next decode reuses and recycles that bitmap, and
the consumer's next draw pass throws. `MauiCustomTarget.clear()` even carries a TODO
acknowledging the C#-side disposal is unreliable:
~~~java
private void clear() {
// TODO: it looks like no one is really disposing the result on C# side
// we must fix it there to release the Glide cache entry properly
~~~
On `Button` the retained reference is `MaterialButton.Icon`, wrapped by
`MauiMaterialButton.MauiResizableDrawable : LayerDrawable` in
`ButtonHandler.Android.cs`, and Material draws the icon as a compound drawable inside
`TextView.onDraw`. That reproduces the reported stack frame for frame:
`TextView.onDraw` → `LayerDrawable.draw` → `BitmapDrawable.draw` → `throwIfCannotDraw`.
**Production impact:** 3,454 crashes over the last 90 days across 319 distinct Android
device models in a single shipping app, ~30–50 per day, all with the blamed frame
`com.microsoft.maui.PlatformContentViewGroup.dispatchDraw`. It is not an edge case.
**Suggested fix:** mirror #12310 on the other target. Give `MauiCustomTarget.onLoadCleared`
a way to notify the consumer (for example extend `ImageLoaderCallback` with an
`onCleared` hook) so the C# `IImageSourcePartSetter.SetImageSource(null)` runs and the
button/toolbar icon drops the stale `Drawable`, exactly as the `ImageView` path already
sets a placeholder.
Related: #12513 (open, `high`/`p/2`) is the same widget and the same background/resume
sequence, but reports the `IllegalArgumentException: You cannot start a load for a
destroyed activity` symptom from `MauiCustomTarget.clear()`; #29780 and #33805 patched
those destroyed-activity paths but left the cleared-resource propagation untouched.
### Steps to Reproduce
1. `dotnet new maui`, Android only.
2. Add several `Button`s whose icon is a `FontImageSource`:
~~~xml
~~~
3. Add an `Image` with an equivalent `FontImageSource` for comparison (this one uses the
already-fixed ImageView path).
4. Enable Developer options → **Don't keep activities**
(`adb shell settings put global always_finish_activities 1`).
5. Run the app, reassign the glyph icons a few times to cycle bitmaps through Glide's pool.
6. Press Home so the Activity is destroyed while the process survives, then reopen from
Recents. Repeat a few times.
**Expected:** the button icons redraw.
**Actual:** `java.lang.RuntimeException: Canvas: trying to use a recycled bitmap` from
`TextView.onDraw`. The `Image` with the same glyph is unaffected.
### Link to public reproduction project repository
### Version with bug
10.0.80
### Is this a regression from previous behavior?
Not sure, did not test other versions
(The `ImageView` path was fixed in #12310 in December 2022; the `CustomTarget` path
appears never to have had the equivalent handling, so this is long-standing rather than a
new regression.)
### Last version that worked well
Unknown/Other
### Affected platforms
Android
### Affected platform versions
Confirmed on Android 16 (Motorola moto g35 5G). Production crashes span 319 distinct
Android device models over a 90-day window.
### Did you find any workaround?
Two, both app-side:
1. Replace the `FontImageSource` image-source service on Android with one that returns a
`Drawable` that draws the glyph directly (`canvas.DrawText`) instead of a Glide-decoded
bitmap, registered via
`ConfigureImageSources(sources => sources.AddService())`.
No bitmap exists, so nothing can be pooled or recycled.
2. Use `Image` / `ImageButton` instead of `Button.ImageSource`, which routes the glyph
through the `MauiCustomViewTarget` path that #12310 already protects.
### Relevant log output
~~~
Fatal Exception: java.lang.RuntimeException: Canvas: trying to use a recycled bitmap android.graphics.Bitmap@fa36b4f
at android.graphics.BaseCanvas.throwIfCannotDraw(BaseCanvas.java:75)
at android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:264)
at android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:97)
at android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:556)
at android.graphics.drawable.LayerDrawable.draw(LayerDrawable.java:1019)
at android.widget.TextView.onDraw(TextView.java:9322)
at android.view.View.draw(View.java:25233)
...
at com.microsoft.maui.PlatformContentViewGroup.dispatchDraw(PlatformContentViewGroup.java:48)
...
at android.view.Choreographer.doFrame(Choreographer.java:1218)
~~~
### Steps to Reproduce
I have attached a project that should reproduce it but i couldn't get it to happen.
### Link to public reproduction project repository
https://github.com/richaplinvs/reproduction-projects/tree/main/maui/maui-repro-glyph-recycled-bitmap
### Version with bug
10.0.90
### Is this a regression from previous behavior?
No, this is something new
### Last version that worked well
Unknown/Other
### Affected platforms
Android
### Affected platform versions
_No response_
### Did you find any workaround?
_No response_
### Relevant log output
```shell
1. Replace the `FontImageSource` image-source service on Android with one that returns a
`Drawable` that draws the glyph directly (`canvas.DrawText`) instead of a Glide-decoded
bitmap, registered via
`ConfigureImageSources(sources => sources.AddService())`.
No bitmap exists, so nothing can be pooled or recycled.
2. Use `Image` / `ImageButton` instead of `Button.ImageSource`, which routes the glyph
through the `MauiCustomViewTarget` path that #12310 already protects.
```
Contributor guide
Research direction
Start in PlatformInterop.java, comparing MauiCustomTarget with the already-fixed MauiCustomViewTarget path, then inspect the retained icon handling in ButtonHandler.Android.cs. Run the linked reproduction or the activity-destruction steps with Button.ImageSource and an Image comparison. Done means button and toolbar icons no longer draw recycled bitmaps after the Activity is destroyed and recreated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, csharp, java
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100