From 875bc43860b5345e88668c6329f7c1dd61919f3a Mon Sep 17 00:00:00 2001 From: Milan Barta Date: Wed, 16 Sep 2026 11:57:23 +0200 Subject: [PATCH] Capture screenshots from the correct window and gate on system memory Screenshots of a DialogFragment destination were copied from the Activity window, which captured the screen behind the dialog. Resolve the dialog's own window in the delegate and pass it to the screenshot holder. The memory check compared the Java heap footprint to its maximum, which refused every capture once the heap had grown, however much was free. Bitmap pixels live on the native heap, so the Java heap never reflected the cost anyway. Gate on ActivityManager.MemoryInfo instead, catch a failed native allocation, and log skipped captures. Skip the capture when the view is no longer attached, which is the case for the second detach during a dialog cancel. --- .../fragments/HotwireWebFragmentDelegate.kt | 9 +- .../util/HotwireViewScreenshotHolder.kt | 87 ++++++++++--------- .../util/HotwireViewScreenshotHolderTest.kt | 42 +++++++++ 3 files changed, 93 insertions(+), 45 deletions(-) create mode 100644 navigation-fragments/src/test/kotlin/dev/hotwire/navigation/util/HotwireViewScreenshotHolderTest.kt diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt index ba9a9a83..91205132 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt @@ -6,6 +6,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -443,8 +444,12 @@ internal class HotwireWebFragmentDelegate( } private suspend fun screenshotView() { - hotwireView?.let { - screenshotHolder.captureScreenshot(it) + hotwireView?.takeIf { it.isAttachedToWindow }?.let { + val window = (navDestination.fragment as? DialogFragment)?.dialog?.window + ?: navDestination.fragment.activity?.window + ?: return + + screenshotHolder.captureScreenshot(it, window) screenshotHolder.showScreenshotIfAvailable(it) } } diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/util/HotwireViewScreenshotHolder.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/util/HotwireViewScreenshotHolder.kt index 8937057b..570f85d4 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/util/HotwireViewScreenshotHolder.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/util/HotwireViewScreenshotHolder.kt @@ -1,17 +1,17 @@ package dev.hotwire.navigation.util -import android.app.Activity +import android.app.ActivityManager import android.content.Context -import android.content.ContextWrapper import android.graphics.Bitmap import android.graphics.Rect import android.os.Handler import android.os.Looper import android.view.PixelCopy -import android.view.View +import android.view.Window import dev.hotwire.navigation.logging.logError import dev.hotwire.navigation.logging.logDebug import dev.hotwire.navigation.views.HotwireView +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -35,30 +35,39 @@ internal class HotwireViewScreenshotHolder { } } - suspend fun captureScreenshot(hotwireView: HotwireView) { - bitmap = copyViewToBitmap(hotwireView) + suspend fun captureScreenshot(hotwireView: HotwireView, window: Window) { + bitmap = copyViewToBitmap(hotwireView, window) screenshotOrientation = hotwireView.currentOrientation() screenshotZoomed = currentlyZoomed } - private suspend fun copyViewToBitmap(hotwireView: HotwireView): Bitmap? { + private suspend fun copyViewToBitmap(hotwireView: HotwireView, window: Window): Bitmap? { return suspendCancellableCoroutine { continuation -> val start = System.currentTimeMillis() - val window = hotwireView.getActivity()?.window + val rect = Rect().also { hotwireView.getGlobalVisibleRect(it) } + val neededBytes = rect.width().toLong() * rect.height() * BYTES_PER_PIXEL + val memoryInfo = hotwireView.context.memoryInfo() - if (window == null || !hotwireView.isLaidOut || !hasEnoughMemoryForScreenshot() || - hotwireView.width <= 0 || hotwireView.height <= 0 - ) { - if (continuation.isActive) { - continuation.resume(null) - } + if (!hotwireView.isLaidOut || rect.isEmpty || !hasEnoughMemory(memoryInfo, neededBytes)) { + logDebug( + "viewScreenshotSkipped", listOf( + "laidOut" to hotwireView.isLaidOut, + "size" to "${rect.width()}x${rect.height()}", + "neededBytes" to neededBytes, + "availableBytes" to memoryInfo.availMem - memoryInfo.threshold, + ) + ) + continuation.resumeIfActive(null) return@suspendCancellableCoroutine } - val rect = Rect() - hotwireView.getGlobalVisibleRect(rect) - - val bitmap = Bitmap.createBitmap(rect.width(), rect.height(), Bitmap.Config.ARGB_8888) + val bitmap = try { + Bitmap.createBitmap(rect.width(), rect.height(), Bitmap.Config.ARGB_8888) + } catch (error: OutOfMemoryError) { + logError("viewScreenshotFailed", error) + continuation.resumeIfActive(null) + return@suspendCancellableCoroutine + } try { PixelCopy.request( @@ -71,45 +80,37 @@ internal class HotwireViewScreenshotHolder { "duration" to "${System.currentTimeMillis() - start}ms", ) ) - if (continuation.isActive) { - continuation.resume(bitmap) - } + continuation.resumeIfActive(bitmap) } else { + bitmap.recycle() logError("viewScreenshotFailed", Exception("PixelCopy failed with result $result")) - if (continuation.isActive) { - continuation.resume(null) - } + continuation.resumeIfActive(null) } }, Handler(Looper.getMainLooper()) ) } catch (exception: Exception) { + bitmap.recycle() logError("viewScreenshotFailed", exception) - if (continuation.isActive) { - continuation.resume(null) - } + continuation.resumeIfActive(null) } } } - private fun hasEnoughMemoryForScreenshot(): Boolean { - val runtime = Runtime.getRuntime() - val used = runtime.totalMemory().toFloat() - val max = runtime.maxMemory().toFloat() - val remaining = 1f - (used / max) + internal fun hasEnoughMemory(memoryInfo: ActivityManager.MemoryInfo, neededBytes: Long): Boolean { + return !memoryInfo.lowMemory && memoryInfo.availMem - memoryInfo.threshold > neededBytes + } - return remaining > .20 + private fun Context.memoryInfo(): ActivityManager.MemoryInfo { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return ActivityManager.MemoryInfo().also { activityManager.getMemoryInfo(it) } } - // Inspired by https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/MediaRouteButton.java#163 - private fun View.getActivity(): Activity? { - var context: Context? = context - while (context is ContextWrapper) { - if (context is Activity) { - return context - } - context = context.baseContext - } - return null + private fun CancellableContinuation.resumeIfActive(value: T) { + if (isActive) resume(value) + } + + companion object { + private const val BYTES_PER_PIXEL = 4L } -} \ No newline at end of file +} diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/util/HotwireViewScreenshotHolderTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/util/HotwireViewScreenshotHolderTest.kt new file mode 100644 index 00000000..aa884d0a --- /dev/null +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/util/HotwireViewScreenshotHolderTest.kt @@ -0,0 +1,42 @@ +package dev.hotwire.navigation.util + +import android.app.ActivityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class HotwireViewScreenshotHolderTest { + private val holder = HotwireViewScreenshotHolder() + private val mb = 1024L * 1024L + private val screenshotBytes = 12 * mb + + @Test + fun `has enough memory when there is headroom above the threshold`() { + val info = memoryInfo(availMem = 1300 * mb, threshold = 216 * mb) + assertThat(holder.hasEnoughMemory(info, screenshotBytes)).isTrue() + } + + @Test + fun `does not have enough memory when the system reports low memory`() { + val info = memoryInfo(availMem = 500 * mb, threshold = 216 * mb, lowMemory = true) + assertThat(holder.hasEnoughMemory(info, screenshotBytes)).isFalse() + } + + @Test + fun `does not have enough memory when the screenshot would cross the threshold`() { + val info = memoryInfo(availMem = 226 * mb, threshold = 216 * mb) + assertThat(holder.hasEnoughMemory(info, screenshotBytes)).isFalse() + } + + @Test + fun `does not have enough memory when the screenshot exactly reaches the threshold`() { + val info = memoryInfo(availMem = 228 * mb, threshold = 216 * mb) + assertThat(holder.hasEnoughMemory(info, screenshotBytes)).isFalse() + } + + private fun memoryInfo(availMem: Long, threshold: Long, lowMemory: Boolean = false) = + ActivityManager.MemoryInfo().also { + it.availMem = availMem + it.threshold = threshold + it.lowMemory = lowMemory + } +}