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