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