blob: 070f92191f7199322df56fcf1949d025ed79dcde [file] [log] [blame]
package com.airbnb.lottie
import android.Manifest
import android.content.res.Resources
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.PointF
import android.util.DisplayMetrics
import android.util.Log
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.updateLayoutParams
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.runner.AndroidJUnit4
import com.airbnb.lottie.model.KeyPath
import com.airbnb.lottie.model.LottieCompositionCache
import com.airbnb.lottie.samples.BuildConfig
import com.airbnb.lottie.samples.SnapshotTestActivity
import com.airbnb.lottie.value.*
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.ListObjectsV2Request
import com.amazonaws.services.s3.model.S3ObjectSummary
import kotlinx.coroutines.*
import org.junit.Before
import com.airbnb.lottie.samples.R as SampleAppR
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
private const val SIZE_PX = 200
/**
* Run these with: ./gradlew recordMode screenshotTests
* If you run that command, it completes successfully, and nothing shows up in git, then you
* haven't broken anything!
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class LottieTest {
@get:Rule
var snapshotActivityRule = ActivityTestRule(SnapshotTestActivity::class.java)
private val activity get() = snapshotActivityRule.activity
@get:Rule
var permissionRule = GrantPermissionRule.grant(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
)
private lateinit var prodAnimationsTransferUtility: TransferUtility
private lateinit var snapshotter: HappoSnapshotter
@Before
fun setup() {
snapshotter = HappoSnapshotter(activity)
prodAnimationsTransferUtility = TransferUtility.builder()
.context(activity)
.s3Client(AmazonS3Client(BasicAWSCredentials(BuildConfig.S3AccessKey, BuildConfig.S3SecretKey)))
.defaultBucket("lottie-prod-animations")
.build()
}
@Test
fun testAll() {
runBlocking {
withTimeout(TimeUnit.MINUTES.toMillis(45)) {
snapshotProdAnimations()
snapshotAssets()
snapshotFrameBoundaries()
snapshotScaleTypes()
testDynamicProperties()
testMarkers()
snapshotter.finalizeReportAndUpload()
}
}
}
private suspend fun snapshotProdAnimations() {
Log.d(L.TAG, "Downloading prod animations from S3.")
val allObjects = mutableListOf<S3ObjectSummary>()
val s3Client = AmazonS3Client(BasicAWSCredentials(BuildConfig.S3AccessKey, BuildConfig.S3SecretKey))
var request = ListObjectsV2Request().apply {
bucketName = "lottie-prod-animations"
}
var result = s3Client.listObjectsV2(request)
allObjects.addAll(result.objectSummaries)
var startAfter = result.objectSummaries.lastOrNull()?.key
while (startAfter != null) {
request = ListObjectsV2Request().apply {
bucketName = "lottie-prod-animations"
this.startAfter = startAfter
}
result = s3Client.listObjectsV2(request)
allObjects.addAll(result.objectSummaries)
startAfter = result.objectSummaries.lastOrNull()?.key
}
allObjects.forEach { snapshotProdAnimation(it) }
}
private suspend fun snapshotProdAnimation(objectSummary: S3ObjectSummary) {
val (fileName, extension) = objectSummary.key.split(".")
val file = File(activity.cacheDir, fileName.md5 + ".$extension")
prodAnimationsTransferUtility.download(objectSummary.key, file).await()
Log.d(L.TAG, "Downloaded ${objectSummary.key}")
val composition = parseComposition(file)
val bitmap = activity.snapshotFilmstrip(composition)
snapshotter.record(bitmap, "prod-" + objectSummary.key, "default")
file.delete()
LottieCompositionCache.getInstance().clear()
}
private suspend fun snapshotAssets(pathPrefix: String = "") {
activity.getAssets().list(pathPrefix)?.forEach { animation ->
if (!animation.contains('.')) {
snapshotAssets(if (pathPrefix.isEmpty()) animation else "$pathPrefix/$animation")
return@forEach
}
if (!animation.endsWith(".json") && !animation.endsWith(".zip")) return@forEach
val composition = parseComposition(if (pathPrefix.isEmpty()) animation else "$pathPrefix/$animation")
val bitmap = activity.snapshotFilmstrip(composition)
snapshotter.record(bitmap, animation, "default")
LottieCompositionCache.getInstance().clear()
}
}
private suspend fun CoroutineScope.snapshotFrameBoundaries() {
Log.d(L.TAG, "snapshotFrameBoundaries")
withAnimationView("Tests/Frame.json", "Frame Boundary", "Frame 16 Red") { animationView ->
Log.d(L.TAG, "Setting frame to 16")
animationView.frame = 16
}
Log.d(L.TAG, "Finished setting frame to 16")
withAnimationView("Tests/Frame.json", "Frame Boundary", "Frame 17 Blue") { animationView ->
animationView.frame = 17
}
withAnimationView("Tests/Frame.json", "Frame Boundary", "Frame 50 Blue") { animationView ->
animationView.frame = 50
}
withAnimationView("Tests/Frame.json", "Frame Boundary", "Frame 51 Green") { animationView ->
animationView.frame = 51
}
withAnimationView("Tests/RGB.json", "Frame Boundary", "Frame 0 Red") { animationView ->
animationView.frame = 0
}
withAnimationView("Tests/RGB.json", "Frame Boundary", "Frame 1 Green") { animationView ->
animationView.frame = 1
}
withAnimationView("Tests/RGB.json", "Frame Boundary", "Frame 2 Blue") { animationView ->
animationView.frame = 2
}
}
private suspend fun CoroutineScope.snapshotScaleTypes() {
withAnimationView("LottieLogo1.json", "Scale Types", "Wrap Content") { animationView ->
animationView.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
withAnimationView("LottieLogo1.json", "Scale Types", "Match Parent") { animationView ->
animationView.updateLayoutParams {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300@2x") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scale = 2f
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300@4x") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scale = 4f
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300 centerCrop") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_CROP
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300 centerInside") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300 centerInside @2x") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE
animationView.scale = 2f
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x300 centerCrop @2x") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 300.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_CROP
animationView.scale = 2f
}
withAnimationView("LottieLogo1.json", "Scale Types", "600x300 centerInside") { animationView ->
animationView.updateLayoutParams {
width = 600.dp.toInt()
height = 300.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE
}
withAnimationView("LottieLogo1.json", "Scale Types", "300x600 centerInside") { animationView ->
animationView.updateLayoutParams {
width = 300.dp.toInt()
height = 600.dp.toInt()
}
animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE
}
}
private suspend fun CoroutineScope.testDynamicProperties() {
testDynamicProperty(
"Fill color (Green)",
KeyPath("Shape Layer 1", "Rectangle", "Fill 1"),
LottieProperty.COLOR,
LottieValueCallback(Color.GREEN))
testDynamicProperty(
"Fill color (Yellow)",
KeyPath("Shape Layer 1", "Rectangle", "Fill 1"),
LottieProperty.COLOR,
LottieValueCallback(Color.YELLOW))
testDynamicProperty(
"Fill opacity",
KeyPath("Shape Layer 1", "Rectangle", "Fill 1"),
LottieProperty.OPACITY,
LottieValueCallback(50))
testDynamicProperty(
"Stroke color",
KeyPath("Shape Layer 1", "Rectangle", "Stroke 1"),
LottieProperty.STROKE_COLOR,
LottieValueCallback(Color.GREEN))
testDynamicProperty(
"Stroke width",
KeyPath("Shape Layer 1", "Rectangle", "Stroke 1"),
LottieProperty.STROKE_WIDTH,
LottieRelativeFloatValueCallback(50f))
testDynamicProperty(
"Stroke opacity",
KeyPath("Shape Layer 1", "Rectangle", "Stroke 1"),
LottieProperty.OPACITY,
LottieValueCallback(50))
testDynamicProperty(
"Transform anchor point",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_ANCHOR_POINT,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Transform position",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_POSITION,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Transform position (relative)",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_POSITION,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Transform opacity",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_OPACITY,
LottieValueCallback(50))
testDynamicProperty(
"Transform rotation",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_ROTATION,
LottieValueCallback(45f))
testDynamicProperty(
"Transform scale",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_SCALE,
LottieValueCallback(ScaleXY(0.5f, 0.5f)))
testDynamicProperty(
"Ellipse position",
KeyPath("Shape Layer 1", "Ellipse", "Ellipse Path 1"),
LottieProperty.POSITION,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Ellipse size",
KeyPath("Shape Layer 1", "Ellipse", "Ellipse Path 1"),
LottieProperty.ELLIPSE_SIZE,
LottieRelativePointValueCallback(PointF(40f, 60f)))
testDynamicProperty(
"Star points",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_POINTS,
LottieValueCallback(8f))
testDynamicProperty(
"Star rotation",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_ROTATION,
LottieValueCallback(10f))
testDynamicProperty(
"Star position",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POSITION,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Star inner radius",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_INNER_RADIUS,
LottieValueCallback(10f))
testDynamicProperty(
"Star inner roundedness",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_INNER_ROUNDEDNESS,
LottieValueCallback(100f))
testDynamicProperty(
"Star outer radius",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_OUTER_RADIUS,
LottieValueCallback(60f))
testDynamicProperty(
"Star outer roundedness",
KeyPath("Shape Layer 1", "Star", "Polystar Path 1"),
LottieProperty.POLYSTAR_OUTER_ROUNDEDNESS,
LottieValueCallback(100f))
testDynamicProperty(
"Polygon points",
KeyPath("Shape Layer 1", "Polygon", "Polystar Path 1"),
LottieProperty.POLYSTAR_POINTS,
LottieValueCallback(8f))
testDynamicProperty(
"Polygon rotation",
KeyPath("Shape Layer 1", "Polygon", "Polystar Path 1"),
LottieProperty.POLYSTAR_ROTATION,
LottieValueCallback(10f))
testDynamicProperty(
"Polygon position",
KeyPath("Shape Layer 1", "Polygon", "Polystar Path 1"),
LottieProperty.POSITION,
LottieRelativePointValueCallback(PointF(20f, 20f)))
testDynamicProperty(
"Polygon radius",
KeyPath("Shape Layer 1", "Polygon", "Polystar Path 1"),
LottieProperty.POLYSTAR_OUTER_RADIUS,
LottieRelativeFloatValueCallback(60f))
testDynamicProperty(
"Polygon roundedness",
KeyPath("Shape Layer 1", "Polygon", "Polystar Path 1"),
LottieProperty.POLYSTAR_OUTER_ROUNDEDNESS,
LottieValueCallback(100f))
testDynamicProperty(
"Repeater transform position",
KeyPath("Shape Layer 1", "Repeater Shape", "Repeater 1"),
LottieProperty.TRANSFORM_POSITION,
LottieRelativePointValueCallback(PointF(100f, 100f)))
testDynamicProperty(
"Repeater transform start opacity",
KeyPath("Shape Layer 1", "Repeater Shape", "Repeater 1"),
LottieProperty.TRANSFORM_START_OPACITY,
LottieValueCallback(25f))
testDynamicProperty(
"Repeater transform end opacity",
KeyPath("Shape Layer 1", "Repeater Shape", "Repeater 1"),
LottieProperty.TRANSFORM_END_OPACITY,
LottieValueCallback(25f))
testDynamicProperty(
"Repeater transform rotation",
KeyPath("Shape Layer 1", "Repeater Shape", "Repeater 1"),
LottieProperty.TRANSFORM_ROTATION,
LottieValueCallback(45f))
testDynamicProperty(
"Repeater transform scale",
KeyPath("Shape Layer 1", "Repeater Shape", "Repeater 1"),
LottieProperty.TRANSFORM_SCALE,
LottieValueCallback(ScaleXY(2f, 2f)))
testDynamicProperty(
"Time remapping",
KeyPath("Circle 1"),
LottieProperty.TIME_REMAP,
LottieValueCallback(1f))
testDynamicProperty(
"Color Filter",
KeyPath("**"),
LottieProperty.COLOR_FILTER,
LottieValueCallback<ColorFilter>(SimpleColorFilter(Color.GREEN)))
withAnimationView("Tests/Shapes.json", "Dynamic Propertiers", "Color Filter after blue") { animationView ->
val blueColorFilter = LottieValueCallback<ColorFilter>(SimpleColorFilter(Color.GREEN))
animationView.addValueCallback(KeyPath("**"), LottieProperty.COLOR_FILTER, blueColorFilter)
val bitmap = activity.snapshotAnimationView()
snapshotter.record(bitmap, "Dynamic Propertiers", "Color Filter before blue")
blueColorFilter.setValue(SimpleColorFilter(Color.BLUE))
}
testDynamicProperty(
"Null Color Filter",
KeyPath("**"),
LottieProperty.COLOR_FILTER,
LottieValueCallback<ColorFilter>(null))
testDynamicProperty(
"Opacity interpolation (0)",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_OPACITY,
LottieInterpolatedIntegerValue(10, 100),
0f)
testDynamicProperty(
"Opacity interpolation (0.5)",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_OPACITY,
LottieInterpolatedIntegerValue(10, 100),
0.5f)
testDynamicProperty(
"Opacity interpolation (1)",
KeyPath("Shape Layer 1", "Rectangle"),
LottieProperty.TRANSFORM_OPACITY,
LottieInterpolatedIntegerValue(10, 100),
1f)
}
private suspend fun <T> CoroutineScope.testDynamicProperty(name: String, keyPath: KeyPath, property: T, callback: LottieValueCallback<T>, progress: Float = 0f) {
withAnimationView("Tests/Shapes.json", "Dynamic Properties", name) { animationView ->
animationView.progress = progress
animationView.addValueCallback(keyPath, property, callback)
}
}
private suspend fun CoroutineScope.testMarkers() {
withAnimationView("Tests/Marker.json", "Marker", "startFrame") { animationView ->
animationView.setMinAndMaxFrame("Marker A")
animationView.frame = animationView.minFrame.toInt()
}
withAnimationView("Tests/Marker.json", "Marker", "endFrame") { animationView ->
animationView.setMinAndMaxFrame("Marker A")
animationView.frame = animationView.maxFrame.toInt()
}
}
private suspend fun CoroutineScope.withAnimationView(
animationName: String,
snapshotName: String? = null,
variant: String = "default",
block: suspend CoroutineScope.(LottieAnimationView) -> Unit
) {
withContext(Dispatchers.Main) {
val animationView = activity.getAnimationView()
animationView.setComposition(parseComposition(animationName))
val layoutParams = animationView.layoutParams
animationView.frame = 0
animationView.scale = 1f
animationView.scaleType
animationView.scaleType = ImageView.ScaleType.FIT_CENTER
Log.d(L.TAG, "Waiting for layout")
animationView.requestLayout()
withContext(Dispatchers.Default) {
suspendCoroutine<Unit> { continuation ->
animationView.post {
continuation.resume(Unit)
}
}
}
block(animationView)
val bitmap = activity.snapshotAnimationView()
snapshotter.record(bitmap, snapshotName ?: animationName, variant)
animationView.layoutParams = layoutParams
animationView.requestLayout()
animationView.scale = 1f
animationView.scaleType = ImageView.ScaleType.FIT_CENTER
LottieCompositionCache.getInstance().clear()
}
}
private suspend fun parseComposition(animationName: String) = suspendCoroutine<LottieComposition> { continuation ->
var isResumed = false
LottieCompositionFactory.fromAsset(activity, animationName)
.addFailureListener {
if (isResumed) return@addFailureListener
continuation.resumeWithException(it)
isResumed = true
}
.addListener {
if (isResumed) return@addListener
continuation.resume(it)
isResumed = true
}
}
private suspend fun parseComposition(file: File) = suspendCoroutine<LottieComposition> { continuation ->
var isResumed = false
val task = if (file.name.endsWith("zip")) LottieCompositionFactory.fromZipStream(ZipInputStream(FileInputStream(file)), file.name)
else LottieCompositionFactory.fromJsonInputStream(FileInputStream(file), file.name)
task
.addFailureListener {
if (isResumed) return@addFailureListener
continuation.resumeWithException(it)
isResumed = true
}
.addListener {
if (isResumed) return@addListener
continuation.resume(it)
isResumed = true
}
}
private val Number.dp get() = this.toFloat() / (Resources.getSystem().displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
}