Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand All @@ -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 <T> CancellableContinuation<T>.resumeIfActive(value: T) {
if (isActive) resume(value)
}

companion object {
private const val BYTES_PER_PIXEL = 4L
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading