• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download

<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