mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
ee6888e737
commit
cd6e39a4a7
|
|
@ -20,6 +20,7 @@
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@android:drawable/ic_media_play"
|
android:icon="@android:drawable/ic_media_play"
|
||||||
android:label="RemoteDisplay"
|
android:label="RemoteDisplay"
|
||||||
|
android:largeHeap="true"
|
||||||
android:theme="@style/Theme.RemoteDisplay"
|
android:theme="@style/Theme.RemoteDisplay"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,17 @@ class MainActivity : AppCompatActivity() {
|
||||||
playerView = playerView,
|
playerView = playerView,
|
||||||
imageView = imageView,
|
imageView = imageView,
|
||||||
youtubeWebView = youtubeWebView,
|
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
|
val cachedJson = config.cachedPlaylist
|
||||||
if (cachedJson.isNotEmpty()) {
|
if (cachedJson.isNotEmpty()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -158,8 +165,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
playlistController.updatePlaylist(assignments)
|
playlistController.updatePlaylist(assignments)
|
||||||
playlistController.startIfNeeded()
|
playlistController.startIfNeeded()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
Log.w("MainActivity", "Failed to restore cached playlist: ${e.message}")
|
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
|
||||||
|
try { config.clearPlaylistCache() } catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.remotedisplay.player.util.ImageLoader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class MediaPlayerManager(
|
class MediaPlayerManager(
|
||||||
|
|
@ -18,7 +19,8 @@ class MediaPlayerManager(
|
||||||
private val playerView: PlayerView,
|
private val playerView: PlayerView,
|
||||||
private val imageView: ImageView,
|
private val imageView: ImageView,
|
||||||
private val youtubeWebView: WebView? = null,
|
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 exoPlayer: ExoPlayer? = null
|
||||||
private var currentType: MediaType = MediaType.NONE
|
private var currentType: MediaType = MediaType.NONE
|
||||||
|
|
@ -89,20 +91,16 @@ class MediaPlayerManager(
|
||||||
|
|
||||||
exoPlayer?.stop()
|
exoPlayer?.stop()
|
||||||
|
|
||||||
// Load image from URL in background
|
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
|
||||||
val connection = java.net.URL(url).openConnection()
|
if (bitmap != null) {
|
||||||
connection.connectTimeout = 10000
|
imageView.post {
|
||||||
connection.readTimeout = 30000
|
try { imageView.setImageBitmap(bitmap) }
|
||||||
val input = connection.getInputStream()
|
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
|
|
||||||
input.close()
|
|
||||||
if (bitmap != null) {
|
|
||||||
imageView.post { imageView.setImageBitmap(bitmap) }
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}")
|
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
|
||||||
|
imageView.post { onImageError?.invoke() }
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
@ -128,24 +126,23 @@ class MediaPlayerManager(
|
||||||
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
|
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
|
||||||
currentType = MediaType.IMAGE
|
currentType = MediaType.IMAGE
|
||||||
|
|
||||||
// Show image, hide player
|
|
||||||
playerView.visibility = android.view.View.GONE
|
playerView.visibility = android.view.View.GONE
|
||||||
imageView.visibility = android.view.View.VISIBLE
|
imageView.visibility = android.view.View.VISIBLE
|
||||||
youtubeWebView?.visibility = android.view.View.GONE
|
youtubeWebView?.visibility = android.view.View.GONE
|
||||||
|
|
||||||
// Stop video if playing
|
|
||||||
exoPlayer?.stop()
|
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 {
|
try {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
|
imageView.setImageBitmap(bitmap)
|
||||||
if (bitmap != null) {
|
} catch (e: Throwable) {
|
||||||
imageView.setImageBitmap(bitmap)
|
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}")
|
||||||
} else {
|
onImageError?.invoke()
|
||||||
Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("MediaPlayerManager", "Error loading image: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,22 +180,30 @@ class ZoneManager(
|
||||||
layoutParams = params
|
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) }
|
val file = contentId?.let { contentCache.getCachedFile(it) }
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
|
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH)
|
||||||
if (bitmap != null) imageView.setImageBitmap(bitmap)
|
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()) {
|
} else if (!remoteUrl.isNullOrEmpty()) {
|
||||||
// Load from URL in background
|
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(remoteUrl, targetW, targetH)
|
||||||
val connection = java.net.URL(remoteUrl).openConnection()
|
if (bitmap != null) {
|
||||||
val input = connection.getInputStream()
|
imageView.post {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
|
try { imageView.setImageBitmap(bitmap) }
|
||||||
input.close()
|
catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
|
||||||
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) }
|
}
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
Log.e(TAG, "Image load failed: ${e.message}")
|
Log.w(TAG, "Zone ${zone.name}: skipping unloadable remote image")
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue