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:icon="@android:drawable/ic_media_play"
|
||||
android:label="RemoteDisplay"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/Theme.RemoteDisplay"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true">
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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