• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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