1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package platform.test.screenshot.report
18 
19 import android.graphics.Bitmap
20 import android.os.Bundle
21 import androidx.test.platform.app.InstrumentationRegistry
22 import java.io.File
23 import java.io.FileOutputStream
24 import java.io.IOException
25 import platform.test.screenshot.GoldenPathManager
26 import platform.test.screenshot.proto.ScreenshotResultProto
27 
28 /**
29  * Writes bitmap diff results to the local test device, for Trade Federation to pick them up and
30  * upload to Scuba.
31  *
32  * TODO(b/322324387) Cleanup code - this is copied with only minor modifications (`testIdentifier`
33  *   is now an argument rather than a member) from http://shortn/_7AMZiumx0f for reviewability.
34  */
35 class ExportToScubaStrategy(
36     private val goldenPathManager: GoldenPathManager,
37 ) : DiffResultExportStrategy {
38     private val imageExtension = ".png"
39     private val resultBinaryProtoFileSuffix = "goldResult.pb"
40 
41     // This is used in CI to identify the files.
42     private val resultProtoFileSuffix = "goldResult.textproto"
43 
44     // Magic number for an in-progress status report
45     private val bundleStatusInProgress = 2
46     private val bundleKeyPrefix = "platform_screenshots_"
47 
reportResultnull48     override fun reportResult(
49         testIdentifier: String,
50         goldenIdentifier: String,
51         actual: Bitmap,
52         status: ScreenshotResultProto.DiffResult.Status,
53         comparisonStatistics: ScreenshotResultProto.DiffResult.ComparisonStatistics?,
54         expected: Bitmap?,
55         diff: Bitmap?
56     ) {
57         val resultProto =
58             ScreenshotResultProto.DiffResult.newBuilder()
59                 .setResultType(status)
60                 .addMetadata(
61                     ScreenshotResultProto.Metadata.newBuilder()
62                         .setKey("repoRootPath")
63                         .setValue(goldenPathManager.deviceLocalPath)
64                 )
65 
66         if (comparisonStatistics != null) {
67             resultProto.comparisonStatistics = comparisonStatistics
68         }
69 
70         val pathRelativeToAssets = goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier)
71         resultProto.imageLocationGolden =
72             "${goldenPathManager.assetsPathRelativeToBuildRoot}/$pathRelativeToAssets"
73 
74         val report = Bundle()
75 
76         actual.writeToDevice(OutputFileType.IMAGE_ACTUAL, goldenIdentifier, testIdentifier).also {
77             resultProto.imageLocationTest = it.name
78             report.putString(bundleKeyPrefix + OutputFileType.IMAGE_ACTUAL, it.absolutePath)
79         }
80         diff?.run {
81             writeToDevice(OutputFileType.IMAGE_DIFF, goldenIdentifier, testIdentifier).also {
82                 resultProto.imageLocationDiff = it.name
83                 report.putString(bundleKeyPrefix + OutputFileType.IMAGE_DIFF, it.absolutePath)
84             }
85         }
86         expected?.run {
87             writeToDevice(OutputFileType.IMAGE_EXPECTED, goldenIdentifier, testIdentifier).also {
88                 resultProto.imageLocationReference = it.name
89                 report.putString(bundleKeyPrefix + OutputFileType.IMAGE_EXPECTED, it.absolutePath)
90             }
91         }
92 
93         writeToDevice(OutputFileType.RESULT_PROTO, goldenIdentifier, testIdentifier) {
94                 it.write(resultProto.build().toString().toByteArray())
95             }
96             .also {
97                 report.putString(bundleKeyPrefix + OutputFileType.RESULT_PROTO, it.absolutePath)
98             }
99 
100         writeToDevice(OutputFileType.RESULT_BIN_PROTO, goldenIdentifier, testIdentifier) {
101                 it.write(resultProto.build().toByteArray())
102             }
103             .also {
104                 report.putString(bundleKeyPrefix + OutputFileType.RESULT_BIN_PROTO, it.absolutePath)
105             }
106 
107         InstrumentationRegistry.getInstrumentation().sendStatus(bundleStatusInProgress, report)
108     }
109 
getPathOnDeviceFornull110     internal fun getPathOnDeviceFor(
111         fileType: OutputFileType,
112         goldenIdentifier: String,
113         testIdentifier: String,
114     ): File {
115         val imageSuffix = getOnDeviceImageSuffix(goldenIdentifier)
116         val protoSuffix = getOnDeviceArtifactsSuffix(goldenIdentifier, resultProtoFileSuffix)
117         val binProtoSuffix =
118             getOnDeviceArtifactsSuffix(goldenIdentifier, resultBinaryProtoFileSuffix)
119         val succinctTestIdentifier = getSuccinctTestIdentifier(testIdentifier)
120         val fileName =
121             when (fileType) {
122                 OutputFileType.IMAGE_ACTUAL -> "${succinctTestIdentifier}_actual_$imageSuffix"
123                 OutputFileType.IMAGE_EXPECTED -> "${succinctTestIdentifier}_expected_$imageSuffix"
124                 OutputFileType.IMAGE_DIFF -> "${succinctTestIdentifier}_diff_$imageSuffix"
125                 OutputFileType.RESULT_PROTO -> "${succinctTestIdentifier}_$protoSuffix"
126                 OutputFileType.RESULT_BIN_PROTO -> "${succinctTestIdentifier}_$binProtoSuffix"
127             }
128         return File(goldenPathManager.deviceLocalPath, fileName)
129     }
130 
getOnDeviceImageSuffixnull131     private fun getOnDeviceImageSuffix(goldenIdentifier: String): String {
132         val resolvedGoldenIdentifier =
133             goldenPathManager
134                 .goldenImageIdentifierResolver(goldenIdentifier)
135                 .replace('/', '_')
136                 .replace(imageExtension, "")
137         return "$resolvedGoldenIdentifier$imageExtension"
138     }
139 
getOnDeviceArtifactsSuffixnull140     private fun getOnDeviceArtifactsSuffix(goldenIdentifier: String, suffix: String): String {
141         val resolvedGoldenIdentifier =
142             goldenPathManager
143                 .goldenImageIdentifierResolver(goldenIdentifier)
144                 .replace('/', '_')
145                 .replace(imageExtension, "")
146         return "${resolvedGoldenIdentifier}_$suffix"
147     }
148 
getSuccinctTestIdentifiernull149     private fun getSuccinctTestIdentifier(identifier: String): String {
150         val pattern = Regex("\\[([A-Za-z0-9_]+)\\]")
151         return pattern.replace(identifier, "")
152     }
153 
Bitmapnull154     private fun Bitmap.writeToDevice(
155         fileType: OutputFileType,
156         goldenIdentifier: String,
157         testIdentifier: String,
158     ): File {
159         return writeToDevice(fileType, goldenIdentifier, testIdentifier) {
160             compress(Bitmap.CompressFormat.PNG, 0 /*ignored for png*/, it)
161         }
162     }
163 
writeToDevicenull164     private fun writeToDevice(
165         fileType: OutputFileType,
166         goldenIdentifier: String,
167         testIdentifier: String,
168         writeAction: (FileOutputStream) -> Unit
169     ): File {
170         val fileGolden = File(goldenPathManager.deviceLocalPath)
171         if (!fileGolden.exists() && !fileGolden.mkdirs()) {
172             throw IOException("Could not create folder $fileGolden.")
173         }
174 
175         val file = getPathOnDeviceFor(fileType, goldenIdentifier, testIdentifier)
176         if (!file.exists()) {
177             // file typically exists when in one test, the same golden image was repeatedly
178             // compared with. In this scenario, multiple actual/expected/diff images with same
179             // names will be attempted to write to the device.
180             try {
181                 FileOutputStream(file).use { writeAction(it) }
182             } catch (e: Exception) {
183                 throw IOException(
184                     "Could not write file to storage (path: ${file.absolutePath}). ",
185                     e
186                 )
187             }
188         }
189 
190         return file
191     }
192 }
193 
194 /** Type of file that can be produced by the [ExportToScubaStrategy]. */
195 internal enum class OutputFileType {
196     IMAGE_ACTUAL,
197     IMAGE_EXPECTED,
198     IMAGE_DIFF,
199     RESULT_PROTO,
200     RESULT_BIN_PROTO
201 }
202