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