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 + } +}