Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial code for Paparazzi interceptors #589

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2022 Block, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.paparazzi

import android.view.View
import java.awt.image.BufferedImage

/**
* Participate in Paparazzi's process of transforming a view into an image.
*
* Implementations may operate on the input view, the returned bitmap, or both.
*/
interface Interceptor {
Copy link
Collaborator

@geoff-powell geoff-powell Oct 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this api as it works similar to retrofit interceptor. This also seems as a API replacement for RenderExtension which is used to render on top of the layout lib output.
(edit noticed RenderExtension.toInterceptor method so you had the same thought :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one issue I know I came across with RenderExtension returning BufferedImage is that there were issues finding java.awt classes for drawing when creating your own instance within your codebase.

fun intercept(chain: Chain): BufferedImage

interface Chain {
val deviceConfig: DeviceConfig
val view: View
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the timing of events with regard to creating the View? Can I set something like a Coil ImageLoader before the view gets rendered?

val snapshot: Snapshot
val frameIndex: Int
val frameCount: Int
val fps: Int
fun proceed(view: View): BufferedImage
}
}
108 changes: 68 additions & 40 deletions paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import app.cash.paparazzi.Interceptor.Chain
import app.cash.paparazzi.agent.AgentTestRule
import app.cash.paparazzi.agent.InterceptorRegistrar
import app.cash.paparazzi.internal.ChoreographerDelegateInterceptor
import app.cash.paparazzi.internal.EditModeInterceptor
import app.cash.paparazzi.internal.IInputMethodManagerInterceptor
import app.cash.paparazzi.internal.ImageUtils
import app.cash.paparazzi.internal.MatrixMatrixMultiplicationInterceptor
import app.cash.paparazzi.internal.MatrixVectorMultiplicationInterceptor
import app.cash.paparazzi.internal.PaparazziCallback
Expand All @@ -74,7 +74,6 @@ import com.android.resources.ScreenRound
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.awt.geom.Ellipse2D
import java.awt.image.BufferedImage
import java.util.Date
import java.util.concurrent.TimeUnit
Expand All @@ -88,9 +87,12 @@ class Paparazzi @JvmOverloads constructor(
private val appCompatEnabled: Boolean = true,
private val maxPercentDifference: Double = 0.1,
private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference),
private val renderExtensions: Set<RenderExtension> = setOf(),
private val supportsRtl: Boolean = false
renderExtensions: Set<RenderExtension> = setOf(),
private val supportsRtl: Boolean = false,
interceptors: List<Interceptor> = defaultInterceptors(deviceConfig)
) : TestRule {
private val renderExtensions = renderExtensions.toSet() // Defensive copy for immutability.
private val interceptors = interceptors.toList() // Defensive copy for immutability.
private val logger = PaparazziLogger()
private lateinit var renderSession: RenderSessionImpl
private lateinit var bridgeRenderSession: RenderSession
Expand Down Expand Up @@ -282,40 +284,74 @@ class Paparazzi @JvmOverloads constructor(
val frameHandler = snapshotHandler.newFrameHandler(snapshot, frameCount, fps)
frameHandler.use {
val viewGroup = bridgeRenderSession.rootViews[0].viewObject as ViewGroup
val modifiedView = renderExtensions.fold(view) { view, renderExtension ->
renderExtension.renderView(view)
}
val allInterceptors = interceptorsToRun(last = RenderViewToBitmapInterceptor(viewGroup, startNanos))

System_Delegate.setBootTimeNanos(0L)
try {
withTime(0L) {
// Initialize the choreographer at time=0.
}

viewGroup.addView(modifiedView)
for (frame in 0 until frameCount) {
val nowNanos = (startNanos + (frame * 1_000_000_000.0 / fps)).toLong()
withTime(nowNanos) {
val result = renderSession.render(true)
if (result.status == ERROR_UNKNOWN) {
throw result.exception
}

val image = bridgeRenderSession.image
frameHandler.handle(scaleImage(frameImage(image)))
}
val chain = RealChain(
nextIndex = 0,
interceptors = allInterceptors,
deviceConfig = deviceConfig,
view = view,
snapshot = snapshot,
frameIndex = frame,
frameCount = frameCount,
fps = fps
)
val image = chain.proceed(view)
frameHandler.handle(image)
}
} finally {
viewGroup.removeView(modifiedView)
viewGroup.removeAllViews()
AnimationHandler.sAnimatorHandler.set(null)
}
}
}

private fun withTime(
private fun interceptorsToRun(last: Interceptor): List<Interceptor> {
return buildList {
this += interceptors
for (renderExtension in renderExtensions) {
this += renderExtension.asInterceptor()
}
this += last
}
}

private fun RenderExtension.asInterceptor() = object : Interceptor {
override fun intercept(chain: Chain) = chain.proceed(renderView(chain.view))
}

/** At the end of the interceptor chain this does the actual render. */
inner class RenderViewToBitmapInterceptor(
private val viewGroup: ViewGroup,
private val startNanos: Long
) : Interceptor {
override fun intercept(chain: Chain): BufferedImage {
if (viewGroup.childCount != 1 || viewGroup.getChildAt(0) != chain.view) {
viewGroup.removeAllViews()
viewGroup.addView(chain.view)
}
val nowNanos = (startNanos + (chain.frameIndex * 1_000_000_000.0 / chain.fps)).toLong()
return withTime(nowNanos) {
val result = renderSession.render(true)
if (result.status == ERROR_UNKNOWN) {
throw result.exception
}
return@withTime bridgeRenderSession.image
}
}
}

private fun <T> withTime(
timeNanos: Long,
block: () -> Unit
) {
block: () -> T
): T {
val frameNanos = TIME_OFFSET_NANOS + timeNanos

// Execute the block at the requested time.
Expand All @@ -336,7 +372,7 @@ class Paparazzi @JvmOverloads constructor(
RenderAction.getCurrentContext().sessionInteractiveData.choreographerCallbacks
choreographerCallbacks.execute(currentTimeMs, Bridge.getLog())

block()
return block()
} catch (e: Throwable) {
Bridge.getLog().error("broken", "Failed executing Choreographer#doFrame", e, null, null)
throw e
Expand Down Expand Up @@ -372,23 +408,6 @@ class Paparazzi @JvmOverloads constructor(
}
}

private fun frameImage(image: BufferedImage): BufferedImage {
if (deviceConfig.screenRound == ScreenRound.ROUND) {
val newImage = BufferedImage(image.width, image.height, image.type)
val g = newImage.createGraphics()
g.clip = Ellipse2D.Float(0f, 0f, image.height.toFloat(), image.width.toFloat())
g.drawImage(image, 0, 0, image.width, image.height, null)
return newImage
}

return image
}

private fun scaleImage(image: BufferedImage): BufferedImage {
val scale = ImageUtils.getThumbnailScale(image)
return ImageUtils.scale(image, scale, scale)
}

private fun Description.toTestName(): TestName {
val fullQualifiedName = className
val packageName = fullQualifiedName.substringBeforeLast('.', missingDelimiterValue = "")
Expand Down Expand Up @@ -628,5 +647,14 @@ class Paparazzi @JvmOverloads constructor(
} else {
HtmlReportWriter()
}

fun defaultInterceptors(deviceConfig: DeviceConfig): List<Interceptor> {
return buildList {
this += ResizeInterceptor.maxAnyDimension(1_000)
if (deviceConfig.screenRound == ScreenRound.ROUND) {
this += RoundFrameInterceptor
}
}
}
}
}
35 changes: 35 additions & 0 deletions paparazzi/paparazzi/src/main/java/app/cash/paparazzi/RealChain.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2022 Block, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.paparazzi

import android.view.View
import java.awt.image.BufferedImage

internal data class RealChain(
val nextIndex: Int,
val interceptors: List<Interceptor>,
override val deviceConfig: DeviceConfig,
override val view: View,
override val snapshot: Snapshot,
override val frameIndex: Int,
override val frameCount: Int,
override val fps: Int
) : Interceptor.Chain {
override fun proceed(view: View): BufferedImage {
val nextChain = copy(nextIndex = nextIndex + 1, view = view)
return interceptors[nextIndex].intercept(nextChain)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2022 Block, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.paparazzi

import android.util.Size
import app.cash.paparazzi.internal.ImageUtils
import java.awt.image.BufferedImage

class ResizeInterceptor private constructor(
val size: (Size) -> Size
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): BufferedImage {
val rendered = chain.proceed(chain.view)
val sourceSize = Size(rendered.width, rendered.height)
val targetSize = size(sourceSize)
return ImageUtils.scale(rendered, targetSize.width, targetSize.height)
}

companion object {
/** Scales images up or down until the largest dimension is [size]. */
fun maxAnyDimension(size: Int): ResizeInterceptor {
return ResizeInterceptor { sourceSize ->
val maxDimension = maxOf(sourceSize.width, sourceSize.height)
val scale = size.toDouble() / maxDimension
Size(
maxOf(1, (scale * sourceSize.width).toInt()),
maxOf(1, (scale * sourceSize.height).toInt())
)
}
}

fun fixedSize(width: Int, height: Int): ResizeInterceptor {
require(width >= 1 && height >= 1)
return ResizeInterceptor { Size(width, height) }
}

fun fixedWidth(width: Int): ResizeInterceptor {
require(width >= 1)
return ResizeInterceptor { sourceSize ->
val scale = width.toDouble() / sourceSize.width
Size(width, maxOf(1, (scale * sourceSize.height).toInt()))
}
}

fun fixedHeight(height: Int): ResizeInterceptor {
require(height >= 1)
return ResizeInterceptor { sourceSize ->
val scale = height.toDouble() / sourceSize.height
Size(maxOf(1, (scale * sourceSize.width).toInt()), height)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2022 Block, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.paparazzi

import java.awt.geom.Ellipse2D
import java.awt.image.BufferedImage

internal object RoundFrameInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): BufferedImage {
val image = chain.proceed(chain.view)
val result = BufferedImage(image.width, image.height, image.type)
val g = result.createGraphics()
try {
g.clip = Ellipse2D.Float(0f, 0f, image.height.toFloat(), image.width.toFloat())
g.drawImage(image, 0, 0, image.width, image.height, null)
return result
} finally {
g.dispose()
}
}
}
Loading