From cd6e39a4a7daa0dba9244d7edd898f6eeb649138 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Apr 2026 10:13:10 -0500 Subject: [PATCH] Fix Android app OOM crash on 4K images and crash loop recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/app/src/main/AndroidManifest.xml | 1 + .../com/remotedisplay/player/MainActivity.kt | 16 +++- .../player/player/MediaPlayerManager.kt | 45 +++++----- .../player/player/ZoneManager.kt | 32 ++++--- .../remotedisplay/player/util/ImageLoader.kt | 86 +++++++++++++++++++ 5 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c2cc5a4..d1643f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index a76d3af..357c399 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -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) {} } } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index f713fc9..47acc81 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -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() - if (bitmap != null) { - imageView.post { imageView.setImageBitmap(bitmap) } + val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context)) + if (bitmap != null) { + 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 + 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 + } 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}") - } - } catch (e: Exception) { - Log.e("MediaPlayerManager", "Error loading image: ${e.message}") + imageView.setImageBitmap(bitmap) + } catch (e: Throwable) { + Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}") + onImageError?.invoke() } } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt index 0d4087a..0615fa0 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt @@ -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() } diff --git a/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt b/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt new file mode 100644 index 0000000..c607496 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt @@ -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 + } +}