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