From 578e77421a172cdcf7b43cd8d7e470b111fdbf61 Mon Sep 17 00:00:00 2001 From: ca-ke Date: Mon, 26 Jan 2026 20:18:06 -0400 Subject: [PATCH] Try Finally blocks to ensure cleanup and prevent OOM crashes --- .../android/node/CameraCaptureManager.kt | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt index 0361c2e55..b677bb1c8 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt @@ -91,49 +91,73 @@ class CameraCaptureManager(private val context: Context) { val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor()) val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") - val rotated = rotateBitmapByExif(decoded, orientation) - val scaled = + + // Wrap bitmap operations in try-finally to guarantee cleanup on all paths + val rotated = try { + rotateBitmapByExif(decoded, orientation) + } catch (e: Exception) { + decoded.recycle() + throw e + } + // Note: if rotation was applied, decoded is already recycled; otherwise rotated === decoded + + val scaled: Bitmap + val recycleRotatedSeparately: Boolean + try { if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) { val h = (rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) .toInt() .coerceAtLeast(1) - rotated.scale(maxWidth, h) + scaled = rotated.scale(maxWidth, h) + recycleRotatedSeparately = true } else { - rotated + scaled = rotated + recycleRotatedSeparately = false } + } catch (e: Exception) { + rotated.recycle() + throw e + } - val maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - val maxEncodedBytes = (maxPayloadBytes / 4) * 3 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = scaled.width, - initialHeight = scaled.height, - startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), - maxBytes = maxEncodedBytes, - encode = { width, height, q -> - val bitmap = - if (width == scaled.width && height == scaled.height) { - scaled - } else { - scaled.scale(width, height) + try { + val maxPayloadBytes = 5 * 1024 * 1024 + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). + val maxEncodedBytes = (maxPayloadBytes / 4) * 3 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = scaled.width, + initialHeight = scaled.height, + startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), + maxBytes = maxEncodedBytes, + encode = { width, height, q -> + val bitmap = + if (width == scaled.width && height == scaled.height) { + scaled + } else { + scaled.scale(width, height) + } + val out = ByteArrayOutputStream() + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { + if (bitmap !== scaled) bitmap.recycle() + throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") } - val out = ByteArrayOutputStream() - if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { - if (bitmap !== scaled) bitmap.recycle() - throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") - } - if (bitmap !== scaled) { - bitmap.recycle() - } - out.toByteArray() - }, + if (bitmap !== scaled) { + bitmap.recycle() + } + out.toByteArray() + }, + ) + val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) + Payload( + """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", ) - val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) - Payload( - """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", - ) + } finally { + scaled.recycle() + if (recycleRotatedSeparately) { + rotated.recycle() + } + } } @SuppressLint("MissingPermission")