<lambda>null1 package com.airbnb.lottie.snapshots.utils
2
3 import android.content.Context
4 import android.graphics.Bitmap
5 import android.os.Build
6 import android.util.Log
7 import com.airbnb.lottie.L
8 import com.airbnb.lottie.snapshots.BuildConfig
9 import com.amazonaws.auth.BasicAWSCredentials
10 import com.amazonaws.mobileconnectors.s3.transferutility.TransferObserver
11 import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility
12 import com.amazonaws.services.s3.AmazonS3Client
13 import com.amazonaws.services.s3.model.CannedAccessControlList
14 import com.google.gson.JsonArray
15 import com.google.gson.JsonElement
16 import com.google.gson.JsonObject
17 import kotlinx.coroutines.*
18 import okhttp3.*
19 import okhttp3.MediaType.Companion.toMediaType
20 import okhttp3.RequestBody.Companion.toRequestBody
21 import java.io.ByteArrayOutputStream
22 import java.io.File
23 import java.io.FileOutputStream
24 import java.io.IOException
25 import java.math.BigInteger
26 import java.net.URLEncoder
27 import java.nio.charset.Charset
28 import java.security.MessageDigest
29 import java.util.*
30 import kotlin.coroutines.CoroutineContext
31 import kotlin.coroutines.resume
32 import kotlin.coroutines.resumeWithException
33 import kotlin.coroutines.suspendCoroutine
34
35 private const val TAG = "HappoSnapshotter"
36
37 /**
38 * Use this class to record Bitmap snapshots and upload them to happo.
39 *
40 * To use it:
41 * 1) Call record with each bitmap you want to save
42 * 2) Call finalizeAndUpload
43 */
44 class HappoSnapshotter(
45 private val context: Context,
46 private val onSnapshotRecorded: (snapshotName: String, snapshotVariant: String) -> Unit,
47 ) {
48 private val recordJob = Job()
49 private val recordScope = CoroutineScope(Dispatchers.IO + recordJob)
50
51 private val bucket = "lottie-happo"
52 private val happoApiKey = BuildConfig.HappoApiKey
53 private val happoSecretKey = BuildConfig.HappoSecretKey
54 private val gitBranch = URLEncoder.encode((if (BuildConfig.BITRISE_GIT_BRANCH == "null") BuildConfig.GIT_BRANCH else BuildConfig.BITRISE_GIT_BRANCH).replace("/", "_"), "UTF-8")
55 private val androidVersion = "android${Build.VERSION.SDK_INT}"
56 private val reportNamePrefixes = listOf(BuildConfig.GIT_SHA, gitBranch, BuildConfig.VERSION_NAME).filter { it.isNotBlank() }
57 // Use this when running snapshots locally.
58 // private val reportNamePrefixes = listOf(System.currentTimeMillis().toString()).filter { it.isNotBlank() }
59 private val reportNames = reportNamePrefixes.map { "$it-$androidVersion" }
60
61 private val okhttp = OkHttpClient()
62
63 private val transferUtility = TransferUtility.builder()
64 .context(context)
65 .s3Client(AmazonS3Client(BasicAWSCredentials(BuildConfig.S3AccessKey, BuildConfig.S3SecretKey)))
66 .defaultBucket(bucket)
67 .build()
68 private val snapshots = mutableListOf<Snapshot>()
69
70 suspend fun record(bitmap: Bitmap, animationName: String, variant: String) = withContext(Dispatchers.IO) {
71 val tempUuid = UUID.randomUUID().toString()
72 val file = File(context.cacheDir, "$tempUuid.png")
73 @Suppress("BlockingMethodInNonBlockingContext")
74 val fileOutputStream = FileOutputStream(file)
75 val byteOutputStream = ByteArrayOutputStream()
76 val outputStream = TeeOutputStream(fileOutputStream, byteOutputStream)
77 // This is the biggest bottleneck in overall performance. Compress + save can take ~75ms per snapshot.
78 bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
79 val md5 = byteOutputStream.toByteArray().md5
80 val key = "snapshots/$md5.png"
81 val md5File = File(context.cacheDir, "$md5.png")
82 file.renameTo(md5File)
83
84 recordScope.launch { uploadDeferred(key, md5File) }
85 Log.d(L.TAG, "Adding snapshot for $animationName-$variant")
86 synchronized(snapshots) {
87 snapshots += Snapshot(bucket, key, bitmap.width, bitmap.height, animationName, variant)
88 }
89 onSnapshotRecorded(animationName, variant)
90 }
91
92 suspend fun finalizeReportAndUpload() {
93 val recordJobStart = System.currentTimeMillis()
94 fun Job.activeJobs() = children.filter { it.isActive }.count()
95 var activeJobs = recordJob.activeJobs()
96 while (activeJobs > 0) {
97 activeJobs = recordJob.activeJobs()
98 Log.d(L.TAG, "Waiting for record $activeJobs jobs to finish.")
99 delay(1000)
100 }
101 recordJob.children.forEach { it.join() }
102 Log.d(L.TAG, "Waited ${System.currentTimeMillis() - recordJobStart}ms for recordings to finish saving.")
103 val json = JsonObject()
104 val snaps = JsonArray()
105 json.add("snaps", snaps)
106 snapshots.forEach { s ->
107 snaps.add(s.toJson())
108 }
109 Log.d(L.TAG, "Finished creating snapshot report")
110 reportNames.forEach { reportName ->
111 Log.d(L.TAG, "Uploading $reportName")
112 upload(reportName, json)
113 }
114 }
115
116 private suspend fun upload(reportName: String, json: JsonElement) {
117 val body = json.toString().toRequestBody("application/json".toMediaType())
118 val request = Request.Builder()
119 .addHeader("Authorization", Credentials.basic(happoApiKey, happoSecretKey, Charset.forName("UTF-8")))
120 .url("https://happo.io/api/reports/$reportName")
121 .post(body)
122 .build()
123
124 val response = okhttp.executeDeferred(request)
125 if (response.isSuccessful) {
126 Log.d(TAG, "Uploaded $reportName to happo")
127 } else {
128 @Suppress("BlockingMethodInNonBlockingContext")
129 throw IllegalStateException("Failed to upload $reportName to Happo. Failed with code ${response.code}. " + response.body?.string())
130 }
131 }
132
133 private suspend fun uploadDeferred(key: String, file: File): TransferObserver {
134 return retry { _, _ ->
135 transferUtility.upload(key, file, CannedAccessControlList.PublicRead).await()
136 }
137 }
138
139 private suspend fun OkHttpClient.executeDeferred(request: Request): Response = suspendCoroutine { continuation ->
140 newCall(request).enqueue(object : Callback {
141 override fun onFailure(call: Call, e: IOException) {
142 continuation.resumeWithException(e)
143 }
144
145 override fun onResponse(call: Call, response: Response) {
146 continuation.resume(response)
147 }
148 })
149 }
150
151 private val ByteArray.md5: String
152 get() {
153 val digest = MessageDigest.getInstance("MD5")
154 digest.update(this, 0, this.size)
155 return BigInteger(1, digest.digest()).toString(16)
156 }
157 }