1 /*
<lambda>null2  * Copyright 2021 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 androidx.benchmark
18 
19 import android.annotation.SuppressLint
20 import android.os.Build
21 import android.os.Environment
22 import android.util.Log
23 import androidx.annotation.RestrictTo
24 import androidx.benchmark.FileMover.moveTo
25 import androidx.test.platform.app.InstrumentationRegistry
26 import java.io.File
27 import java.text.SimpleDateFormat
28 import java.util.Date
29 import java.util.TimeZone
30 
31 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
32 object Outputs {
33 
34     private val formatter: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss")
35 
36     /**
37      * Matches substrings to be removed from filenames.
38      *
39      * We only allow digits, ascii letters, `_` and `-` to remain.
40      *
41      * Note `-` is important for baseline profiles, see b/303034735
42      */
43     private val sanitizerRegex = Regex("([^0-9a-zA-Z._-]+)")
44 
45     /** The intended output directory that respects the `additionalTestOutputDir`. */
46     val outputDirectory: File
47 
48     /**
49      * The usable output directory, given permission issues with `adb shell` on Android R. Both the
50      * app and the shell have access to this output folder.
51      *
52      * This dir can be read/written by app This dir can be read by shell (see
53      * [forceFilesForShellAccessible] for API 21/22!)
54      */
55     val dirUsableByAppAndShell: File
56 
57     /**
58      * Any file created by this process for the shell to use must be explicitly made filesystem
59      * globally readable, as prior to API 23 the shell didn't have access by default.
60      */
61     val forceFilesForShellAccessible: Boolean = Build.VERSION.SDK_INT in 21..22
62 
63     init {
64         // Be explicit about the TimeZone for stable formatting
65         formatter.timeZone = TimeZone.getTimeZone("UTC")
66 
67         val context = InstrumentationRegistry.getInstrumentation().targetContext
68         @SuppressLint("NewApi")
69         dirUsableByAppAndShell =
70             when {
71                 Build.VERSION.SDK_INT >= 29 -> {
72                     // On Android Q+ we are using the media directory because that is
73                     // the directory that the shell has access to. Context: b/181601156
74                     // Additionally, Benchmarks append user space traces to the ones produced
75                     // by the Macro Benchmark run; and that is a lot simpler to do if we use the
76                     // Media directory. (b/216588251)
77                     @Suppress("DEPRECATION")
78                     context.externalMediaDirs.firstOrNull {
79                         Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED
80                     }
81                 }
82                 Build.VERSION.SDK_INT <= 22 -> {
83                     // prior to API 23, shell didn't have access to externalCacheDir
84                     context.cacheDir
85                 }
86                 else -> context.externalCacheDir
87             }
88                 ?: throw IllegalStateException(
89                     "Unable to select a directory for writing files, " +
90                         "additionalTestOutputDir argument required to declare output dir."
91                 )
92 
93         if (forceFilesForShellAccessible) {
94             // By default, shell doesn't have access to app dirs on 21/22 so we need to modify
95             // this so that the shell can output here too
96             dirUsableByAppAndShell.setReadable(true, false)
97             dirUsableByAppAndShell.setWritable(true, false)
98             dirUsableByAppAndShell.setExecutable(true, false)
99         }
100 
101         Log.d(BenchmarkState.TAG, "Usable output directory: $dirUsableByAppAndShell")
102 
103         outputDirectory =
104             Arguments.additionalTestOutputDir?.let { File(it) } ?: dirUsableByAppAndShell
105 
106         Log.d(BenchmarkState.TAG, "Output Directory: $outputDirectory")
107 
108         // Clear all the existing files in the output directories
109         listOf(outputDirectory, dirUsableByAppAndShell).forEach {
110             it.listFiles()?.forEach { file -> if (file.isFile) file.delete() }
111         }
112 
113         // Ensure output dir is created
114         outputDirectory.mkdirs()
115     }
116 
117     /**
118      * Create a benchmark output [File] to write to.
119      *
120      * This method handles reporting files to `InstrumentationStatus` to request copy, writing them
121      * in the desired output directory, and handling shell access issues on Android R.
122      *
123      * @return The absolute path of the output [File].
124      */
125     fun writeFile(
126         fileName: String,
127         reportOnRunEndOnly: Boolean = false,
128         block: (file: File) -> Unit,
129     ): String {
130         val sanitizedName = sanitizeFilename(fileName)
131         val destination = File(outputDirectory, sanitizedName)
132 
133         // We override the `additionalTestOutputDir` argument.
134         // Context: b/181601156
135         val file = File(dirUsableByAppAndShell, sanitizedName)
136         block.invoke(file)
137         check(file.exists()) { "File doesn't exist!" }
138 
139         if (dirUsableByAppAndShell != outputDirectory) {
140             // We need to copy files over anytime `dirUsableByAppAndShell` is different from
141             // `outputDirectory`.
142             Log.d(BenchmarkState.TAG, "Moving $file to $destination")
143             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
144                 file.moveTo(destination, overwrite = true)
145             } else {
146                 file.copyTo(destination, overwrite = true)
147                 file.delete()
148             }
149         }
150 
151         InstrumentationResults.reportAdditionalFileToCopy(
152             key = sanitizedName,
153             absoluteFilePath = destination.absolutePath,
154             reportOnRunEndOnly = reportOnRunEndOnly
155         )
156         return destination.absolutePath
157     }
158 
159     fun sanitizeFilename(filename: String): String {
160         require(filename.length < 200) {
161             // Check length instead of sanitizing because in practice, names this long will
162             // break AGP/Studio/Desktop side tooling as well, at least on Linux.
163             // This threshold is conservative and operates on the input as, in practice, Studio
164             // tooling expands testnames into filenames a bit more than benchmark does.
165             "Filename too long (${filename.length} > 200) $filename - trim your test name, or" +
166                 " parameterization string to avoid filename too long exceptions"
167         }
168         return filename.replace(sanitizerRegex, "_")
169     }
170 
171     fun testOutputFile(filename: String): File {
172         return File(outputDirectory, filename)
173     }
174 
175     fun dateToFileName(date: Date = Date()): String {
176         return formatter.format(date)
177     }
178 
179     fun relativePathFor(path: String): String {
180         val hasOutputDirectoryPrefix = path.startsWith(outputDirectory.absolutePath)
181         val relativePath =
182             when {
183                 hasOutputDirectoryPrefix -> path.removePrefix("${outputDirectory.absolutePath}/")
184                 else -> path.removePrefix("${dirUsableByAppAndShell.absolutePath}/")
185             }
186         check(relativePath != path) { "$relativePath == $path" }
187         return relativePath
188     }
189 }
190