1 /*
<lambda>null2 * 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 package com.google.jetpackcamera.utils
17
18 import android.app.Activity
19 import android.app.Instrumentation
20 import android.content.ComponentName
21 import android.content.Intent
22 import android.graphics.BitmapFactory
23 import android.media.MediaMetadataRetriever
24 import android.net.Uri
25 import android.os.Build
26 import android.provider.MediaStore
27 import android.util.Log
28 import androidx.compose.ui.semantics.SemanticsProperties
29 import androidx.compose.ui.test.SemanticsMatcher
30 import androidx.lifecycle.Lifecycle
31 import androidx.test.core.app.ActivityScenario
32 import androidx.test.platform.app.InstrumentationRegistry
33 import androidx.test.rule.GrantPermissionRule
34 import androidx.test.uiautomator.By
35 import androidx.test.uiautomator.UiDevice
36 import androidx.test.uiautomator.UiObject2
37 import androidx.test.uiautomator.Until
38 import com.google.common.truth.Truth.assertWithMessage
39 import java.io.File
40 import java.net.URLConnection
41 import java.util.concurrent.TimeoutException
42 import kotlin.coroutines.CoroutineContext
43 import kotlin.time.Duration
44 import kotlin.time.Duration.Companion.seconds
45 import kotlinx.coroutines.Dispatchers
46 import kotlinx.coroutines.NonCancellable
47 import kotlinx.coroutines.cancelAndJoin
48 import kotlinx.coroutines.delay
49 import kotlinx.coroutines.flow.take
50 import kotlinx.coroutines.launch
51 import kotlinx.coroutines.runBlocking
52 import kotlinx.coroutines.withContext
53 import kotlinx.coroutines.withTimeoutOrNull
54 import org.junit.Assert.fail
55 import org.junit.rules.TestRule
56 import org.junit.runner.Description
57 import org.junit.runners.model.Statement
58
59 const val APP_START_TIMEOUT_MILLIS = 10_000L
60 const val SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS = 5_000L
61 const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L
62 const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L
63 const val VIDEO_DURATION_MILLIS = 3_000L
64 const val MESSAGE_DISAPPEAR_TIMEOUT_MILLIS = 15_000L
65 const val VIDEO_PREFIX = "video"
66 const val IMAGE_PREFIX = "image"
67 const val COMPONENT_PACKAGE_NAME = "com.google.jetpackcamera"
68 const val COMPONENT_CLASS = "com.google.jetpackcamera.MainActivity"
69 private const val TAG = "UiTestUtil"
70
71 inline fun <reified T : Activity> runMediaStoreAutoDeleteScenarioTest(
72 mediaUri: Uri,
73 filePrefix: String = "",
74 expectedNumFiles: Int = 1,
75 fileWaitTimeoutMs: Duration = 10.seconds,
76 fileObserverContext: CoroutineContext = Dispatchers.IO,
77 crossinline block: ActivityScenario<T>.() -> Unit
78 ) = runBlocking {
79 val debugTag = "MediaStoreAutoDelete"
80 val instrumentation = InstrumentationRegistry.getInstrumentation()
81 val insertedMediaStoreEntries = mutableMapOf<String, Uri>()
82 val observeFilesJob = launch(fileObserverContext) {
83 mediaStoreInsertedFlow(
84 mediaUri = mediaUri,
85 instrumentation = instrumentation,
86 filePrefix = filePrefix
87 ).take(expectedNumFiles)
88 .collect {
89 Log.d(debugTag, "Discovered new media store file: ${it.first}")
90 insertedMediaStoreEntries[it.first] = it.second
91 }
92 }
93
94 var succeeded = false
95 try {
96 runScenarioTest(block = block)
97 succeeded = true
98 } finally {
99 withContext(NonCancellable) {
100 if (!succeeded ||
101 withTimeoutOrNull(fileWaitTimeoutMs) {
102 // Wait for normal completion with timeout
103 observeFilesJob.join()
104 } == null
105 ) {
106 // If the test didn't succeed, or we've timed out waiting for files,
107 // cancel file observer and ensure job is complete
108 observeFilesJob.cancelAndJoin()
109 }
110
111 val detectedNumFiles = insertedMediaStoreEntries.size
112 // Delete all inserted files that we know about at this point
113 insertedMediaStoreEntries.forEach {
114 Log.d(debugTag, "Deleting media store file: $it")
115 val deletedRows = instrumentation.targetContext.contentResolver.delete(
116 it.value,
117 null,
118 null
119 )
120 if (deletedRows > 0) {
121 Log.d(debugTag, "Deleted $deletedRows files")
122 } else {
123 Log.e(debugTag, "Failed to delete ${it.key}")
124 }
125 }
126
127 if (succeeded) {
128 assertWithMessage("Expected number of saved files does not match detected number")
129 .that(detectedNumFiles).isEqualTo(expectedNumFiles)
130 }
131 }
132 }
133 }
134
runScenarioTestnull135 inline fun <reified T : Activity> runScenarioTest(
136 crossinline block: ActivityScenario<T>.() -> Unit
137 ) {
138 ActivityScenario.launch(T::class.java).use { scenario ->
139 scenario.apply(block)
140 }
141 }
142
runScenarioTestForResultnull143 inline fun <reified T : Activity> runScenarioTestForResult(
144 intent: Intent,
145 crossinline block: ActivityScenario<T>.() -> Unit
146 ): Instrumentation.ActivityResult {
147 ActivityScenario.launchActivityForResult<T>(intent).use { scenario ->
148 scenario.apply(block)
149 return runBlocking { scenario.pollResult() }
150 }
151 }
152
153 // Workaround for https://github.com/android/android-test/issues/676
pollResultnull154 suspend inline fun <reified T : Activity> ActivityScenario<T>.pollResult(
155 // Choose timeout to match
156 // https://github.com/android/android-test/blob/67fa7cb12b9a14dc790b75947f4241c3063e80dc/runner/monitor/java/androidx/test/internal/platform/app/ActivityLifecycleTimeout.java#L22
157 timeout: Duration = 45.seconds
158 ): Instrumentation.ActivityResult = withTimeoutOrNull(timeout) {
159 // Poll for the state to be destroyed before we return the result
160 while (state != Lifecycle.State.DESTROYED) {
161 delay(100)
162 }
163 checkNotNull(result)
164 } ?: run {
165 throw TimeoutException(
166 "Timed out while waiting for activity result. Waited $timeout."
167 )
168 }
169
getTestUrinull170 fun getTestUri(directoryPath: String, timeStamp: Long, suffix: String): Uri = Uri.fromFile(
171 File(
172 directoryPath,
173 "$timeStamp.$suffix"
174 )
175 )
176
177 fun deleteFilesInDirAfterTimestamp(
178 directoryPath: String,
179 instrumentation: Instrumentation,
180 timeStamp: Long
181 ): Boolean {
182 var hasDeletedFile = false
183 for (file in File(directoryPath).listFiles() ?: emptyArray()) {
184 if (file.lastModified() >= timeStamp) {
185 file.delete()
186 if (file.exists()) {
187 file.canonicalFile.delete()
188 if (file.exists()) {
189 instrumentation.targetContext.applicationContext.deleteFile(file.name)
190 }
191 }
192 hasDeletedFile = true
193 }
194 }
195 return hasDeletedFile
196 }
197
<lambda>null198 fun doesFileExist(uri: Uri): Boolean = uri.path?.let { File(it) }?.exists() == true
199
doesMediaExistnull200 fun doesMediaExist(uri: Uri, prefix: String): Boolean {
201 require(prefix == IMAGE_PREFIX || prefix == VIDEO_PREFIX) { "Uknown prefix: $prefix" }
202 return if (prefix == IMAGE_PREFIX) {
203 doesImageExist(uri)
204 } else {
205 doesVideoExist(uri, prefix)
206 }
207 }
208
doesImageExistnull209 private fun doesImageExist(uri: Uri): Boolean {
210 val bitmap = uri.path?.let { path -> BitmapFactory.decodeFile(path) }
211 val mimeType = URLConnection.guessContentTypeFromName(uri.path)
212 return mimeType != null && mimeType.startsWith(IMAGE_PREFIX) && bitmap != null
213 }
214
doesVideoExistnull215 private fun doesVideoExist(
216 uri: Uri,
217 prefix: String,
218 checkAudio: Boolean = false,
219 durationMs: Long? = null
220 ): Boolean {
221 require(prefix == VIDEO_PREFIX) {
222 "doesVideoExist() only works for videos. Can't handle prefix: $prefix"
223 }
224
225 if (!doesFileExist(uri)) {
226 return false
227 }
228 return MediaMetadataRetriever().useAndRelease {
229 it.setDataSource(uri.path)
230
231 it.getMimeType().startsWith(prefix) &&
232 it.hasVideo() &&
233 (!checkAudio || it.hasAudio()) &&
234 (durationMs == null || it.getDurationMs() == durationMs)
235 } == true
236 }
237
getSingleImageCaptureIntentnull238 fun getSingleImageCaptureIntent(uri: Uri, action: String): Intent {
239 val intent = Intent(action)
240 intent.setComponent(
241 ComponentName(
242 COMPONENT_PACKAGE_NAME,
243 COMPONENT_CLASS
244 )
245 )
246 intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
247 return intent
248 }
249
getMultipleImageCaptureIntentnull250 fun getMultipleImageCaptureIntent(uriStrings: ArrayList<String>?, action: String): Intent {
251 val intent = Intent(action)
252 intent.setComponent(
253 ComponentName(
254 COMPONENT_PACKAGE_NAME,
255 COMPONENT_CLASS
256 )
257 )
258 intent.putStringArrayListExtra(MediaStore.EXTRA_OUTPUT, uriStrings)
259 return intent
260 }
261
<lambda>null262 fun stateDescriptionMatches(expected: String?) = SemanticsMatcher("stateDescription is $expected") {
263 SemanticsProperties.StateDescription in it.config &&
264 (it.config[SemanticsProperties.StateDescription] == expected)
265 }
266
267 /**
268 * Rule to specify test methods that will have permissions granted prior to running
269 *
270 * @param permissions the permissions to be granted
271 * @param targetTestNames the names of the tests that this rule will apply to
272 */
273 class IndividualTestGrantPermissionRule(
274 private val permissions: Array<String>,
275 private val targetTestNames: Array<String>
276 ) : TestRule {
277 private lateinit var wrappedRule: GrantPermissionRule
278
applynull279 override fun apply(base: Statement, description: Description): Statement {
280 for (targetName in targetTestNames) {
281 if (description.methodName == targetName) {
282 wrappedRule = GrantPermissionRule.grant(*permissions)
283 return wrappedRule.apply(base, description)
284 }
285 }
286 // If no match, return the base statement without granting permissions
287 return base
288 }
289 }
290
291 // functions for interacting with system permission dialog
askEveryTimeDialognull292 fun UiDevice.askEveryTimeDialog() {
293 if (Build.VERSION.SDK_INT >= 30) {
294 Log.d(TAG, "Searching for Allow Once Button...")
295
296 val askPermission = this.findObjectById(
297 resId = "com.android.permissioncontroller:id/permission_allow_one_time_button"
298 )
299
300 Log.d(TAG, "Clicking Allow Once Button")
301
302 askPermission?.click()
303 }
304 }
305
306 /**
307 * Clicks ALLOW option on an open permission dialog
308 */
UiDevicenull309 fun UiDevice.grantPermissionDialog() {
310 if (Build.VERSION.SDK_INT >= 23) {
311 Log.d(TAG, "Searching for Allow Button...")
312
313 val allowPermission = this.findObjectById(
314 resId = when {
315 Build.VERSION.SDK_INT <= 29 ->
316 "com.android.packageinstaller:id/permission_allow_button"
317 else ->
318 "com.android.permissioncontroller:id/permission_allow_foreground_only_button"
319 }
320 )
321 Log.d(TAG, "Clicking Allow Button")
322
323 allowPermission?.click()
324 }
325 }
326
327 /**
328 * Clicks the DENY option on an open permission dialog
329 */
denyPermissionDialognull330 fun UiDevice.denyPermissionDialog() {
331 if (Build.VERSION.SDK_INT >= 23) {
332 Log.d(TAG, "Searching for Deny Button...")
333 val denyPermission = this.findObjectById(
334 resId = when {
335 Build.VERSION.SDK_INT <= 29 ->
336 "com.android.packageinstaller:id/permission_deny_button"
337 else -> "com.android.permissioncontroller:id/permission_deny_button"
338 }
339 )
340 Log.d(TAG, "Clicking Deny Button")
341
342 denyPermission?.click()
343 }
344 }
345
346 /**
347 * Finds a system button by its resource ID.
348 * fails if not found
349 */
UiDevicenull350 private fun UiDevice.findObjectById(
351 resId: String,
352 timeout: Long = 10000,
353 shouldFailIfNotFound: Boolean = true
354 ): UiObject2? {
355 val selector = By.res(resId)
356 return if (!this.wait(Until.hasObject(selector), timeout)) {
357 if (shouldFailIfNotFound) {
358 fail("Could not find object with RESOURCE ID: $resId")
359 }
360 null
361 } else {
362 this.findObject(selector)
363 }
364 }
365