<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.airbnb.lottie.snapshots.R
10 import com.amazonaws.auth.BasicAWSCredentials
11 import com.amazonaws.mobileconnectors.s3.transferutility.TransferObserver
12 import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility
13 import com.amazonaws.services.s3.AmazonS3Client
14 import com.amazonaws.services.s3.model.CannedAccessControlList
15 import com.google.gson.JsonArray
16 import com.google.gson.JsonElement
17 import com.google.gson.JsonObject
18 import kotlinx.coroutines.CoroutineScope
19 import kotlinx.coroutines.Dispatchers
20 import kotlinx.coroutines.Job
21 import kotlinx.coroutines.delay
22 import kotlinx.coroutines.launch
23 import kotlinx.coroutines.withContext
24 import okhttp3.Call
25 import okhttp3.Callback
26 import okhttp3.Credentials
27 import okhttp3.MediaType.Companion.toMediaType
28 import okhttp3.OkHttpClient
29 import okhttp3.Request
30 import okhttp3.RequestBody.Companion.toRequestBody
31 import okhttp3.Response
32 import java.io.ByteArrayOutputStream
33 import java.io.File
34 import java.io.FileOutputStream
35 import java.io.IOException
36 import java.math.BigInteger
37 import java.net.URLEncoder
38 import java.nio.charset.Charset
39 import java.security.KeyStore
40 import java.security.MessageDigest
41 import java.security.cert.CertificateFactory
42 import java.security.cert.X509Certificate
43 import java.util.UUID
44 import javax.net.ssl.SSLContext
45 import javax.net.ssl.TrustManagerFactory
46 import javax.net.ssl.X509TrustManager
47 import kotlin.coroutines.resume
48 import kotlin.coroutines.resumeWithException
49 import kotlin.coroutines.suspendCoroutine
50
51 private const val TAG = "HappoSnapshotter"
52
53 /**
54 * Use this class to record Bitmap snapshots and upload them to happo.
55 *
56 * To use it:
57 * 1) Call record with each bitmap you want to save
58 * 2) Call finalizeAndUpload
59 */
60 class HappoSnapshotter(
61 private val context: Context,
62 s3AccessKey: String,
63 s3SecretKey: String,
64 private val happoApiKey: String,
65 private val happoSecretKey: String,
66 private val onSnapshotRecorded: (snapshotName: String, snapshotVariant: String) -> Unit,
67 ) {
68 private val recordJob = Job()
69 private val recordScope = CoroutineScope(Dispatchers.IO + recordJob)
70
71 private val bucket = "lottie-happo"
72 private val gitBranch = URLEncoder.encode((BuildConfig.GIT_BRANCH).replace("/", "_"), "UTF-8")
73 private val androidVersion = "android${Build.VERSION.SDK_INT}"
74 private val reportNamePrefixes = listOf(BuildConfig.GIT_SHA, gitBranch, BuildConfig.VERSION_NAME).filter { it.isNotBlank() }
75
76 // Use this when running snapshots locally.
77 // private val reportNamePrefixes = listOf(System.currentTimeMillis().toString()).filter { it.isNotBlank() }
78 private val reportNames = reportNamePrefixes.map { "$it-$androidVersion" }
79
80 private val okhttp by lazy {
81 // https://androiddev.social/@botteaap/112108241212116279
82 // https://letsencrypt.org/2023/07/10/cross-sign-expiration.html
83 // https://letsencrypt.org/certs/isrgrootx1.der
84 val ca: X509Certificate = context.resources.openRawResource(R.raw.isrgrootx1).use {
85 CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate
86 }
87
88 val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
89 keyStore.load(null, null)
90 keyStore.setCertificateEntry("ca", ca)
91
92 val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
93 trustManagerFactory.init(keyStore)
94
95 val sslContext: SSLContext = SSLContext.getInstance("TLS")
96 sslContext.init(null, trustManagerFactory.trustManagers, null)
97
98 OkHttpClient.Builder()
99 .sslSocketFactory(sslContext.socketFactory, trustManagerFactory.trustManagers[0] as X509TrustManager)
100 .build()
101 }
102
103 private val transferUtility = TransferUtility.builder()
104 .context(context)
105 .s3Client(AmazonS3Client(BasicAWSCredentials(s3AccessKey, s3SecretKey)))
106 .defaultBucket(bucket)
107 .build()
108 private val snapshots = mutableListOf<Snapshot>()
109
110 suspend fun record(bitmap: Bitmap, animationName: String, variant: String) = withContext(Dispatchers.IO) {
111 val tempUuid = UUID.randomUUID().toString()
112 val file = File(context.cacheDir, "$tempUuid.png")
113
114 val fileOutputStream = FileOutputStream(file)
115 val byteOutputStream = ByteArrayOutputStream()
116 val outputStream = TeeOutputStream(fileOutputStream, byteOutputStream)
117 // This is the biggest bottleneck in overall performance. Compress + save can take ~75ms per snapshot.
118 bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
119 val md5 = byteOutputStream.toByteArray().md5
120 val key = "snapshots/$md5.png"
121 val md5File = File(context.cacheDir, "$md5.png")
122 file.renameTo(md5File)
123
124 recordScope.launch { uploadDeferred(key, md5File) }
125 Log.d(L.TAG, "Adding snapshot for $animationName-$variant")
126 synchronized(snapshots) {
127 snapshots += Snapshot(bucket, key, bitmap.width, bitmap.height, animationName, variant)
128 }
129 onSnapshotRecorded(animationName, variant)
130 }
131
132 suspend fun finalizeReportAndUpload() {
133 val recordJobStart = System.currentTimeMillis()
134 fun Job.activeJobs() = children.filter { it.isActive }.count()
135 var activeJobs = recordJob.activeJobs()
136 while (activeJobs > 0) {
137 activeJobs = recordJob.activeJobs()
138 Log.d(L.TAG, "Waiting for record $activeJobs jobs to finish.")
139 delay(1000)
140 }
141 recordJob.children.forEach { it.join() }
142 Log.d(L.TAG, "Waited ${System.currentTimeMillis() - recordJobStart}ms for recordings to finish saving.")
143 val json = JsonObject()
144 val snaps = JsonArray()
145 json.add("snaps", snaps)
146 snapshots.forEach { s ->
147 snaps.add(s.toJson())
148 }
149 Log.d(L.TAG, "Finished creating snapshot report")
150 reportNames.forEach { reportName ->
151 Log.d(L.TAG, "Uploading $reportName")
152 upload(reportName, json)
153 }
154 }
155
156 private suspend fun upload(reportName: String, json: JsonElement) {
157 val body = json.toString().toRequestBody("application/json".toMediaType())
158 val request = Request.Builder()
159 .addHeader("Authorization", Credentials.basic(happoApiKey, happoSecretKey, Charset.forName("UTF-8")))
160 .url("https://happo.io/api/reports/$reportName")
161 .post(body)
162 .build()
163
164 val response = okhttp.executeDeferred(request)
165 if (response.isSuccessful) {
166 Log.d(TAG, "Uploaded $reportName to happo")
167 } else {
168 throw IllegalStateException("Failed to upload $reportName to Happo. Failed with code ${response.code}. " + response.body?.string())
169 }
170 }
171
172 private suspend fun uploadDeferred(key: String, file: File): TransferObserver {
173 return retry { _, _ ->
174 transferUtility.upload(key, file, CannedAccessControlList.PublicRead).await()
175 }
176 }
177
178 private suspend fun OkHttpClient.executeDeferred(request: Request): Response = suspendCoroutine { continuation ->
179 newCall(request).enqueue(object : Callback {
180 override fun onFailure(call: Call, e: IOException) {
181 continuation.resumeWithException(e)
182 }
183
184 override fun onResponse(call: Call, response: Response) {
185 continuation.resume(response)
186 }
187 })
188 }
189
190 private val ByteArray.md5: String
191 get() {
192 val digest = MessageDigest.getInstance("MD5")
193 digest.update(this, 0, this.size)
194 return BigInteger(1, digest.digest()).toString(16)
195 }
196 }
197