Fix Android app OOM crash on 4K images and crash loop recovery

A 4K image assigned to a 1080p display decoded as a ~33 MB ARGB_8888
bitmap and OOM'd. Worse, the cached playlist on disk meant relaunch
hit the same image and crashed again — only a reinstall recovered.

New ImageLoader utility reads bounds via inJustDecodeBounds, computes
inSampleSize against the device screen (or zone size for multi-zone
layouts), and returns null on OOM/Throwable so callers skip the item
instead of crashing. MediaPlayerManager exposes an onImageError
callback wired to playlistController.next() so a bad item advances
the playlist. The cached-playlist restore in onCreate now catches
Throwable (was Exception) and clears the cache on any failure,
breaking the crash loop. android:largeHeap="true" added as belt and
braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-28 10:13:10 -05:00
parent ee6888e737
commit cd6e39a4a7
5 changed files with 140 additions and 40 deletions

View file

@ -20,6 +20,7 @@
android:allowBackup="true"
android:icon="@android:drawable/ic_media_play"
android:label="RemoteDisplay"
android:largeHeap="true"
android:theme="@style/Theme.RemoteDisplay"
android:usesCleartextTraffic="true"
android:supportsRtl="true">

View file

@ -144,10 +144,17 @@ class MainActivity : AppCompatActivity() {
playerView = playerView,
imageView = imageView,
youtubeWebView = youtubeWebView,
onVideoComplete = { playlistController.onVideoComplete() }
onVideoComplete = { playlistController.onVideoComplete() },
onImageError = {
Log.w("MainActivity", "Image failed to load, skipping to next item")
handler.postDelayed({ playlistController.next() }, 500)
}
)
// Restore cached playlist for offline cold-start (play immediately from disk cache)
// Restore cached playlist for offline cold-start (play immediately from disk cache).
// Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app
// before the WebSocket connects — that's the crash-loop scenario. If the cache is
// unusable for any reason, drop it and continue; the server will resend on connect.
val cachedJson = config.cachedPlaylist
if (cachedJson.isNotEmpty()) {
try {
@ -158,8 +165,9 @@ class MainActivity : AppCompatActivity() {
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
}
} catch (e: Exception) {
Log.w("MainActivity", "Failed to restore cached playlist: ${e.message}")
} catch (e: Throwable) {
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
try { config.clearPlaylistCache() } catch (_: Throwable) {}
}
}

View file

@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.remotedisplay.player.util.ImageLoader
import java.io.File
class MediaPlayerManager(
@ -18,7 +19,8 @@ class MediaPlayerManager(
private val playerView: PlayerView,
private val imageView: ImageView,
private val youtubeWebView: WebView? = null,
private val onVideoComplete: () -> Unit
private val onVideoComplete: () -> Unit,
private val onImageError: (() -> Unit)? = null
) {
private var exoPlayer: ExoPlayer? = null
private var currentType: MediaType = MediaType.NONE
@ -89,20 +91,16 @@ class MediaPlayerManager(
exoPlayer?.stop()
// Load image from URL in background
Thread {
try {
val connection = java.net.URL(url).openConnection()
connection.connectTimeout = 10000
connection.readTimeout = 30000
val input = connection.getInputStream()
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
input.close()
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
if (bitmap != null) {
imageView.post { imageView.setImageBitmap(bitmap) }
imageView.post {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
}
} catch (e: Exception) {
Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}")
} else {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
imageView.post { onImageError?.invoke() }
}
}.start()
}
@ -128,24 +126,23 @@ class MediaPlayerManager(
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
currentType = MediaType.IMAGE
// Show image, hide player
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
// Stop video if playing
exoPlayer?.stop()
// Load image
try {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) {
imageView.setImageBitmap(bitmap)
} else {
Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
val bitmap = ImageLoader.decodeFile(file, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable image: ${file.name}")
onImageError?.invoke()
return
}
} catch (e: Exception) {
Log.e("MediaPlayerManager", "Error loading image: ${e.message}")
try {
imageView.setImageBitmap(bitmap)
} catch (e: Throwable) {
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}")
onImageError?.invoke()
}
}

View file

@ -180,22 +180,30 @@ class ZoneManager(
layoutParams = params
}
// Load image
// Target the zone size (already known) so we don't decode larger than the
// visible region. Falls back to screen size if zone hasn't been measured.
val targetW = if (w > 0) w else com.remotedisplay.player.util.ImageLoader.screenWidth(context)
val targetH = if (h > 0) h else com.remotedisplay.player.util.ImageLoader.screenHeight(context)
val file = contentId?.let { contentCache.getCachedFile(it) }
if (file != null) {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) imageView.setImageBitmap(bitmap)
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH)
if (bitmap != null) {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
} else {
Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId")
}
} else if (!remoteUrl.isNullOrEmpty()) {
// Load from URL in background
Thread {
try {
val connection = java.net.URL(remoteUrl).openConnection()
val input = connection.getInputStream()
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
input.close()
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) }
} catch (e: Exception) {
Log.e(TAG, "Image load failed: ${e.message}")
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(remoteUrl, targetW, targetH)
if (bitmap != null) {
imageView.post {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
}
} else {
Log.w(TAG, "Zone ${zone.name}: skipping unloadable remote image")
}
}.start()
}

View file

@ -0,0 +1,86 @@
package com.remotedisplay.player.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import java.io.File
import java.net.URL
/**
* Safe bitmap loader. Reads dimensions first via inJustDecodeBounds, then decodes
* with an inSampleSize that scales the image down to the device's screen resolution.
* A 4K source image on a 1080p screen ends up as 1920x1080, not 3840x2160 keeps
* the bitmap under ~8 MB instead of ~33 MB.
*
* All exceptions, including OutOfMemoryError, return null so the caller can skip the
* item rather than crashing the whole app.
*/
object ImageLoader {
private const val TAG = "ImageLoader"
fun screenWidth(ctx: Context): Int = ctx.resources.displayMetrics.widthPixels
fun screenHeight(ctx: Context): Int = ctx.resources.displayMetrics.heightPixels
fun decodeFile(file: File, maxW: Int, maxH: Int): Bitmap? {
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
Log.w(TAG, "Invalid image dimensions for ${file.name}")
return null
}
val opts = BitmapFactory.Options().apply {
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
}
BitmapFactory.decodeFile(file.absolutePath, opts)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM decoding ${file.name}: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to decode ${file.name}: ${e.message}")
null
}
}
fun decodeUrl(url: String, maxW: Int, maxH: Int): Bitmap? {
return try {
val bytes = URL(url).openConnection().apply {
connectTimeout = 10_000
readTimeout = 30_000
}.getInputStream().use { it.readBytes() }
decodeBytes(bytes, maxW, maxH)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM downloading $url: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to download $url: ${e.message}")
null
}
}
private fun decodeBytes(bytes: ByteArray, maxW: Int, maxH: Int): Bitmap? {
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val opts = BitmapFactory.Options().apply {
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM decoding ${bytes.size} bytes: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to decode ${bytes.size} bytes: ${e.message}")
null
}
}
private fun calcSampleSize(srcW: Int, srcH: Int, maxW: Int, maxH: Int): Int {
if (maxW <= 0 || maxH <= 0) return 1
var sample = 1
while (srcW / sample > maxW || srcH / sample > maxH) sample *= 2
return sample
}
}