• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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 com.android.captiveportallogin
18 
19 import android.app.Activity
20 import android.content.ComponentName
21 import android.content.Context
22 import android.content.Intent
23 import android.content.ServiceConnection
24 import android.content.res.Configuration
25 import android.net.Network
26 import android.net.Uri
27 import android.os.Build
28 import android.os.Bundle
29 import android.os.IBinder
30 import android.os.Parcel
31 import android.os.Parcelable
32 import android.os.SystemClock
33 import android.util.Log
34 import android.widget.TextView
35 import androidx.annotation.ChecksSdkIntAtLeast
36 import androidx.core.content.FileProvider
37 import androidx.test.core.app.ActivityScenario
38 import androidx.test.ext.junit.runners.AndroidJUnit4
39 import androidx.test.filters.SmallTest
40 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
41 import androidx.test.rule.ServiceTestRule
42 import androidx.test.uiautomator.By
43 import androidx.test.uiautomator.UiDevice
44 import androidx.test.uiautomator.UiObject
45 import androidx.test.uiautomator.UiScrollable
46 import androidx.test.uiautomator.UiSelector
47 import androidx.test.uiautomator.Until
48 import com.android.captiveportallogin.DownloadService.DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE
49 import com.android.captiveportallogin.DownloadService.DownloadServiceBinder
50 import com.android.captiveportallogin.DownloadService.ProgressCallback
51 import com.android.modules.utils.build.SdkLevel.isAtLeastS
52 import com.android.testutils.ConnectivityDiagnosticsCollector
53 import com.android.testutils.DeviceInfoUtils
54 import com.android.testutils.runCommandInRootShell
55 import com.android.testutils.runCommandInShell
56 import java.io.ByteArrayInputStream
57 import java.io.File
58 import java.io.FileInputStream
59 import java.io.InputStream
60 import java.io.InputStreamReader
61 import java.net.HttpURLConnection
62 import java.net.URL
63 import java.net.URLConnection
64 import java.nio.charset.StandardCharsets
65 import java.util.concurrent.CompletableFuture
66 import java.util.concurrent.SynchronousQueue
67 import java.util.concurrent.TimeUnit.MILLISECONDS
68 import kotlin.math.min
69 import kotlin.random.Random
70 import kotlin.test.assertEquals
71 import kotlin.test.assertFalse
72 import kotlin.test.assertNotEquals
73 import kotlin.test.assertTrue
74 import kotlin.test.fail
75 import org.junit.AfterClass
76 import org.junit.Assert.assertNotNull
77 import org.junit.Assume.assumeFalse
78 import org.junit.Before
79 import org.junit.BeforeClass
80 import org.junit.Rule
81 import org.junit.Test
82 import org.junit.rules.TestWatcher
83 import org.junit.runner.Description
84 import org.junit.runner.RunWith
85 import org.mockito.Mockito.doReturn
86 import org.mockito.Mockito.mock
87 import org.mockito.Mockito.timeout
88 import org.mockito.Mockito.verify
89 
90 private val TEST_FILESIZE = 1_000_000 // 1MB
91 private val TEST_USERAGENT = "Test UserAgent"
92 private val TEST_URL = "https://test.download.example.com/myfile"
93 private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller"
94 
95 // Test text file registered in the test manifest to be opened by a test activity
96 private val TEST_TEXT_FILE_EXTENSION = "testtxtfile"
97 private val TEST_TEXT_FILE_TYPE = "text/vnd.captiveportallogin.testtxtfile"
98 
99 private val TEST_TIMEOUT_MS = 10_000L
100 
101 // Timeout for notifications before trying to find it via scrolling
102 private val NOTIFICATION_NO_SCROLL_TIMEOUT_MS = 1000L
103 
104 // Maximum number of scrolls from the top to attempt to find notifications in the notification shade
105 private val NOTIFICATION_SCROLL_COUNT = 30
106 
107 // Swipe in a vertically centered area of 20% of the screen height (40% margin
108 // top/down): small swipes on notifications avoid dismissing the notification shade
109 private val NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT = .4
110 
111 // Steps for each scroll in the notification shade (controls the scrolling speed).
112 // Each scroll is a series of cursor moves between multiple points on a line. The delay between each
113 // point is hard-coded, so the number of points (steps) controls how long the scroll takes.
114 private val NOTIFICATION_SCROLL_STEPS = 5
115 private val NOTIFICATION_SCROLL_POLL_MS = 100L
116 
117 private val TEST_WIFI_CONFIG_TYPE = "application/x-wifi-config"
118 
119 private val TAG = DownloadServiceTest::class.simpleName
120 
121 private val random = Random(SystemClock.elapsedRealtimeNanos())
122 
123 @Rule
124 val mServiceRule = ServiceTestRule()
125 
126 @RunWith(AndroidJUnit4::class)
127 @SmallTest
128 class DownloadServiceTest {
129     companion object {
130         private var originalTraceBufferSizeKb = 0
131 
132         // To identify which process is deleting test files during the run (b/317602748), enable
133         // tracing for file deletion in f2fs (the filesystem used for /data on test devices) and
134         // process creation/exit
135         private const val tracePath = "/sys/kernel/tracing"
136         private val traceEnablePaths = listOf(
137             "$tracePath/events/f2fs/f2fs_unlink_enter",
138             "$tracePath/events/sched/sched_process_exec",
139             "$tracePath/events/sched/sched_process_fork",
140             "$tracePath/events/sched/sched_process_exit",
141             "$tracePath/tracing_on"
142         )
143 
144         @JvmStatic
145         @BeforeClass
setUpClassnull146         fun setUpClass() {
147             if (!enableTracing()) return
148             val originalSize = runCommandInShell("cat $tracePath/buffer_size_kb").trim()
149             // Buffer size may be small on boot when tracing is disabled, and automatically expanded
150             // when enabled (buffer_size_kb will report  something like: "7 (expanded: 1408)"). As
151             // only fixed values can be used when resetting, reset to the expanded size in that
152             // case.
153             val match = Regex("([0-9]+)|[0-9]+ \\(expanded: ([0-9]+)\\)")
154                 .matchEntire(originalSize)
155                 ?: fail("Could not parse original buffer size: $originalSize")
156             originalTraceBufferSizeKb = (match.groups[2]?.value ?: match.groups[1]?.value)?.toInt()
157                 ?: fail("Buffer size not found in $originalSize")
158             traceEnablePaths.forEach {
159                 runCommandInRootShell("echo 1 > $it")
160             }
161             runCommandInRootShell("echo 96000 > $tracePath/buffer_size_kb")
162         }
163 
164         @JvmStatic
165         @AfterClass
tearDownClassnull166         fun tearDownClass() {
167             if (!enableTracing()) return
168             traceEnablePaths.asReversed().forEach {
169                 runCommandInRootShell("echo 0 > $it")
170             }
171             runCommandInRootShell("echo $originalTraceBufferSizeKb > $tracePath/buffer_size_kb")
172         }
173 
174         @ChecksSdkIntAtLeast(Build.VERSION_CODES.S)
enableTracingnull175         fun enableTracing() = DeviceInfoUtils.isDebuggable() && isAtLeastS()
176     }
177 
178     @get:Rule
179     val collectTraceOnFailureRule = object : TestWatcher() {
180         override fun failed(e: Throwable, description: Description) {
181             if (!enableTracing()) return
182             ConnectivityDiagnosticsCollector.instance?.let {
183                 it.collectCommandOutput("su 0 cat $tracePath/trace")
184             }
185         }
186     }
187 
188     private val connection = mock(HttpURLConnection::class.java)
189 
<lambda>null190     private val context by lazy { getInstrumentation().context }
<lambda>null191     private val resources by lazy { context.resources }
<lambda>null192     private val device by lazy { UiDevice.getInstance(getInstrumentation()) }
193 
194     // Test network that can be parceled in intents while mocking the connection
195     class TestNetwork(private val privateDnsBypass: Boolean = false) :
196         Network(43, privateDnsBypass) {
197         companion object {
198             // Subclasses of parcelable classes need to define a CREATOR field of their own (which
199             // hides the one of the parent class), otherwise the CREATOR field of the parent class
200             // would be used when unparceling and createFromParcel would return an instance of the
201             // parent class.
202             @JvmField
203             val CREATOR = object : Parcelable.Creator<TestNetwork> {
createFromParcelnull204                 override fun createFromParcel(source: Parcel?) = TestNetwork()
205                 override fun newArray(size: Int) = emptyArray<TestNetwork>()
206             }
207 
208             /**
209              * Test [URLConnection] to be returned by all [TestNetwork] instances when
210              * [openConnection] is called.
211              *
212              * This can be set to a mock connection, and is a static to allow [TestNetwork]s to be
213              * parceled and unparceled without losing their mock configuration.
214              */
215             internal var sTestConnection: HttpURLConnection? = null
216         }
217 
218         override fun getPrivateDnsBypassingCopy(): Network {
219             // Note that the privateDnsBypass flag is not kept when parceling/unparceling: this
220             // mirrors the real behavior of that flag in Network.
221             // The test relies on this to verify that after setting privateDnsBypass to true,
222             // the TestNetwork is not parceled / unparceled, which would clear the flag both
223             // for TestNetwork or for a real Network and be a bug.
224             return TestNetwork(privateDnsBypass = true)
225         }
226 
openConnectionnull227         override fun openConnection(url: URL?): URLConnection {
228             // Verify that this network was created with privateDnsBypass = true, and was not
229             // parceled / unparceled afterwards (which would have cleared the flag).
230             assertTrue(
231                 privateDnsBypass,
232                     "Captive portal downloads should be done on a network bypassing private DNS"
233             )
234             return sTestConnection ?: throw IllegalStateException(
235                     "Mock URLConnection not initialized")
236         }
237     }
238 
239     /**
240      * A test InputStream returning generated data.
241      *
242      * Reading this stream is not thread-safe: it should only be read by one thread at a time.
243      */
244     private class TestInputStream(private var available: Int = 0) : InputStream() {
245         // position / available are only accessed in the reader thread
246         private var position = 0
247 
248         private val nextAvailableQueue = SynchronousQueue<Int>()
249 
250         /**
251          * Set how many bytes are available now without blocking.
252          *
253          * This is to be set on a thread controlling the amount of data that is available, while
254          * a reader thread may be trying to read the data.
255          *
256          * The reader thread will block until this value is increased, and if the reader is not yet
257          * waiting for the data to be made available, this method will block until it is.
258          */
setAvailablenull259         fun setAvailable(newAvailable: Int) {
260             assertTrue(
261                 nextAvailableQueue.offer(
262                     newAvailable.coerceIn(0, TEST_FILESIZE),
263                     TEST_TIMEOUT_MS,
264                     MILLISECONDS
265                 ),
266                     "Timed out waiting for TestInputStream to be read"
267             )
268         }
269 
readnull270         override fun read(): Int {
271             throw NotImplementedError("read() should be unused")
272         }
273 
274         /**
275          * Attempt to read [len] bytes at offset [off].
276          *
277          * This will block until some data is available if no data currently is (so this method
278          * never returns 0 if [len] > 0).
279          */
readnull280         override fun read(b: ByteArray, off: Int, len: Int): Int {
281             if (position >= TEST_FILESIZE) return -1 // End of stream
282 
283             while (available <= position) {
284                 available = nextAvailableQueue.take()
285             }
286 
287             // Read the requested bytes (but not more than available).
288             val remaining = available - position
289             val readLen = min(len, remaining)
290             for (i in 0 until readLen) {
291                 b[off + i] = (position % 256).toByte()
292                 position++
293             }
294 
295             return readLen
296         }
297     }
298 
299     @Before
setUpnull300     fun setUp() {
301         TestNetwork.sTestConnection = connection
302         doReturn(200).`when`(connection).responseCode
303         doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong
304 
305         ActivityScenario.launch(RequestDismissKeyguardActivity::class.java)
306     }
307 
assumeCanDisplayNotificationsnull308     private fun assumeCanDisplayNotifications() {
309         val isTvUi = (resources.configuration.uiMode and Configuration.UI_MODE_TYPE_TELEVISION) != 0
310         // See https://tv.withgoogle.com/patterns/notifications.html
311         assumeFalse("TVs don't display notifications", isTvUi)
312     }
313 
314     /**
315      * Create a temporary, empty file that can be used to read/write data for testing.
316      */
createTestFilenull317     private fun createTestFile(extension: String = ".png"): File {
318         // The test file provider uses the files dir (not cache dir or external files dir or...), as
319         // declared in its file_paths XML referenced from the manifest.
320         val testFilePath = File(
321             context.getFilesDir(),
322                 CaptivePortalLoginActivity.FILE_PROVIDER_DOWNLOAD_PATH
323         )
324         testFilePath.mkdir()
325         // Do not use File.createTempFile, as it generates very long filenames that may not
326         // fit in notifications, making it difficult to find the right notification.
327         // Use 8 digits to fit the filename and a bit more text, even on very small screens (320 dp,
328         // minimum CDD size).
329         var index = random.nextInt(100_000_000)
330         while (true) {
331             val file = File(testFilePath, "tmp$index$extension")
332             if (!file.exists()) {
333                 // createNewFile only returns false if the file already exists (it throws on error)
334                 assertTrue(file.createNewFile(), "$file was created after exists() check")
335                 return file
336             }
337             index++
338         }
339     }
340 
341     /**
342      * Make a file URI based on a file on disk, using a [FileProvider] that is registered for the
343      * test app.
344      */
makeFileUrinull345     private fun makeFileUri(testFile: File) = FileProvider.getUriForFile(
346             context,
347             // File provider registered in the test manifest
348             "com.android.captiveportallogin.tests.fileprovider",
349             testFile
350     )
351 
352     @Test
353     fun testDownloadFile() {
354         assumeCanDisplayNotifications()
355 
356         val inputStream1 = TestInputStream()
357         doReturn(inputStream1).`when`(connection).inputStream
358 
359         val testFile1 = createTestFile()
360         val testFile2 = createTestFile()
361         assertTrue(testFile1.exists(), "$testFile1 did not exist after creation")
362         assertTrue(testFile2.exists(), "$testFile2 did not exist after creation")
363 
364         assertNotEquals(testFile1.name, testFile2.name)
365         openNotificationShade()
366 
367         assertTrue(testFile1.exists(), "$testFile1 did not exist before starting download")
368         assertTrue(testFile2.exists(), "$testFile2 did not exist before starting download")
369 
370         // Queue both downloads immediately: they should be started in order
371         val binder = bindService(makeDownloadCompleteCallback())
372         startDownloadTask(binder, testFile1, TEST_TEXT_FILE_TYPE)
373         startDownloadTask(binder, testFile2, TEST_TEXT_FILE_TYPE)
374 
375         try {
376             verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream
377         } finally {
378             Log.i(TAG, "testFile1 exists after connecting: ${testFile1.exists()}")
379             Log.i(TAG, "testFile2 exists after connecting: ${testFile2.exists()}")
380         }
381         val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name)
382 
383         findNotification(UiSelector().textContains(dlText1))
384 
385         // Allow download to progress to 1%
386         assertEquals(0, TEST_FILESIZE % 100)
387         assertTrue(TEST_FILESIZE / 100 > 0)
388         inputStream1.setAvailable(TEST_FILESIZE / 100)
389 
390         // Setup the connection for the next download with indeterminate progress
391         val inputStream2 = TestInputStream()
392         doReturn(inputStream2).`when`(connection).inputStream
393         doReturn(-1L).`when`(connection).contentLengthLong
394 
395         // Allow the first download to finish
396         inputStream1.setAvailable(TEST_FILESIZE)
397         verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect()
398 
399         FileInputStream(testFile1).use {
400             assertSameContents(it, TestInputStream(TEST_FILESIZE))
401         }
402 
403         testFile1.delete()
404 
405         // The second download should have started: make some data available
406         inputStream2.setAvailable(TEST_FILESIZE / 100)
407 
408         // A notification should be shown for the second download with indeterminate progress
409         val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name)
410         findNotification(UiSelector().textContains(dlText2))
411 
412         // Allow the second download to finish
413         inputStream2.setAvailable(TEST_FILESIZE)
414         verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect()
415 
416         FileInputStream(testFile2).use {
417             assertSameContents(it, TestInputStream(TEST_FILESIZE))
418         }
419 
420         testFile2.delete()
421     }
422 
makeDownloadCompleteCallbacknull423     fun makeDownloadCompleteCallback(
424         directlyOpenCompleteFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
425         downloadCompleteFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
426         downloadAbortedFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
427         expectReason: Int = -1
428     ): ServiceConnection {
429         // Test callback to receive download completed callback.
430         return object : ServiceConnection {
431             override fun onServiceDisconnected(name: ComponentName) {}
432             override fun onServiceConnected(name: ComponentName, binder: IBinder) {
433                 val callback = object : ProgressCallback {
434                     override fun onDownloadComplete(
435                         inputFile: Uri,
436                         mimeType: String,
437                         downloadId: Int,
438                         success: Boolean
439                     ) {
440                         if (TEST_WIFI_CONFIG_TYPE.equals(mimeType)) {
441                             directlyOpenCompleteFuture.complete(success)
442                         } else {
443                             downloadCompleteFuture.complete(success)
444                         }
445                     }
446 
447                     override fun onDownloadAborted(downloadId: Int, reason: Int) {
448                         if (expectReason == reason) downloadAbortedFuture.complete(true)
449                     }
450                 }
451 
452                 (binder as DownloadServiceBinder).setProgressCallback(callback)
453             }
454         }
455     }
456 
457     @Test
testDirectlyOpenMimeType_fileSizeTooLargenull458     fun testDirectlyOpenMimeType_fileSizeTooLarge() {
459         val inputStream1 = TestInputStream()
460         doReturn(inputStream1).`when`(connection).inputStream
461         getInstrumentation().waitForIdleSync()
462         val outCfgFile = createTestDirectlyOpenFile()
463         val downloadAbortedFuture = CompletableFuture<Boolean>()
464         val mTestServiceConn = makeDownloadCompleteCallback(
465                 downloadAbortedFuture = downloadAbortedFuture,
466                 expectReason = DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE
467         )
468 
469         try {
470             val binder = bindService(mTestServiceConn)
471             startDownloadTask(binder, outCfgFile, TEST_WIFI_CONFIG_TYPE)
472             inputStream1.setAvailable(TEST_FILESIZE)
473             // File size 1_000_000 is bigger than the limit(100_000). Download is expected to be
474             // aborted. Verify callback called when the download is complete.
475             assertTrue(downloadAbortedFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
476         } finally {
477             mServiceRule.unbindService()
478         }
479     }
480 
481     @Test
testDirectlyOpenMimeType_cancelTasknull482     fun testDirectlyOpenMimeType_cancelTask() {
483         val inputStream1 = TestInputStream()
484         doReturn(inputStream1).`when`(connection).inputStream
485 
486         val outCfgFile = createTestDirectlyOpenFile()
487         val outTextFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION")
488 
489         val directlyOpenCompleteFuture = CompletableFuture<Boolean>()
490         val otherCompleteFuture = CompletableFuture<Boolean>()
491         val testServiceConn = makeDownloadCompleteCallback(
492                 directlyOpenCompleteFuture = directlyOpenCompleteFuture,
493                 downloadCompleteFuture = otherCompleteFuture
494         )
495 
496         try {
497             val binder = bindService(testServiceConn)
498             // Start directly open task first then follow with a generic one
499             val directlydlId = startDownloadTask(binder, outCfgFile, TEST_WIFI_CONFIG_TYPE)
500             startDownloadTask(binder, outTextFile, TEST_TEXT_FILE_TYPE)
501 
502             inputStream1.setAvailable(TEST_FILESIZE / 100)
503             // Cancel directly open task. The directly open task should result in a failed download
504             // complete. The cancel intent should not affect the other download task.
505             binder.cancelTask(directlydlId)
506             inputStream1.setAvailable(TEST_FILESIZE)
507             assertFalse(directlyOpenCompleteFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
508             assertTrue(otherCompleteFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
509         } finally {
510             mServiceRule.unbindService()
511         }
512     }
513 
createTestDirectlyOpenFilenull514     private fun createTestDirectlyOpenFile() = createTestFile(extension = ".wificonfig")
515 
516     private fun bindService(serviceConn: ServiceConnection): DownloadServiceBinder {
517         val binder = mServiceRule.bindService(
518             Intent(context, DownloadService::class.java),
519                 serviceConn,
520             Context.BIND_AUTO_CREATE
521         ) as DownloadServiceBinder
522         assertNotNull(binder)
523         return binder
524     }
525 
startDownloadTasknull526     private fun startDownloadTask(
527         binder: DownloadServiceBinder,
528         file: File,
529         mimeType: String
530     ): Int {
531         return binder.requestDownload(
532                 TestNetwork(),
533                 TEST_USERAGENT,
534                 TEST_URL,
535                 file.name,
536                 makeFileUri(file),
537                 context,
538                mimeType
539         )
540     }
541 
542     @Test
testTapDoneNotificationnull543     fun testTapDoneNotification() {
544         assumeCanDisplayNotifications()
545 
546         val fileContents = "Test file contents"
547         val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8))
548         doReturn(bis).`when`(connection).inputStream
549 
550         // The test extension is handled by OpenTextFileActivity in the test package
551         val testFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION")
552         openNotificationShade()
553 
554         val binder = bindService(makeDownloadCompleteCallback())
555         startDownloadTask(binder, testFile, TEST_TEXT_FILE_TYPE)
556 
557         // The download completed notification has the filename as contents, and
558         // R.string.download_completed as title. Find the contents using the filename as exact match
559         val note = findNotification(UiSelector().text(testFile.name))
560         note.click()
561 
562         // OpenTextFileActivity opens the file and shows contents
563         assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS))
564     }
565 
openNotificationShadenull566     private fun openNotificationShade() {
567         device.wakeUp()
568         device.openNotification()
569         assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS))
570     }
571 
findNotificationnull572     private fun findNotification(selector: UiSelector): UiObject {
573         val shadeScroller = UiScrollable(UiSelector().resourceId(NOTIFICATION_SHADE_TYPE))
574                 .setSwipeDeadZonePercentage(NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT)
575 
576         // Optimistically wait for the notification without scrolling (scrolling is slow)
577         val note = shadeScroller.getChild(selector)
578         if (note.waitForExists(NOTIFICATION_NO_SCROLL_TIMEOUT_MS)) return note
579 
580         val limit = System.currentTimeMillis() + TEST_TIMEOUT_MS
581         while (System.currentTimeMillis() < limit) {
582             // Similar to UiScrollable.scrollIntoView, but do not scroll up before going down (it
583             // could open the quick settings), and control the scroll steps (with a large swipe
584             // dead zone, scrollIntoView uses too many steps by default and is very slow).
585             for (i in 0 until NOTIFICATION_SCROLL_COUNT) {
586                 val canScrollFurther = shadeScroller.scrollForward(NOTIFICATION_SCROLL_STEPS)
587                 if (note.exists()) return note
588                 // Scrolled to the end, or scrolled too much and closed the shade
589                 if (!canScrollFurther || !shadeScroller.exists()) break
590             }
591 
592             // Go back to the top: close then reopen the notification shade.
593             // Do not scroll up, as it could open quick settings (and would be slower).
594             device.pressHome()
595             assertTrue(shadeScroller.waitUntilGone(TEST_TIMEOUT_MS))
596             openNotificationShade()
597 
598             Thread.sleep(NOTIFICATION_SCROLL_POLL_MS)
599         }
600         fail("Notification with selector $selector not found")
601     }
602 
603     /**
604      * Verify that two [InputStream] have the same content by reading them until the end of stream.
605      */
assertSameContentsnull606     private fun assertSameContents(s1: InputStream, s2: InputStream) {
607         val buffer1 = ByteArray(1000)
608         val buffer2 = ByteArray(1000)
609         while (true) {
610             // Read one chunk from s1
611             val read1 = s1.read(buffer1, 0, buffer1.size)
612             if (read1 < 0) break
613 
614             // Read a chunk of the same size from s2
615             var read2 = 0
616             while (read2 < read1) {
617                 s2.read(buffer2, read2, read1 - read2).also {
618                     assertFalse(it < 0, "Stream 2 is shorter than stream 1")
619                     read2 += it
620                 }
621             }
622             assertEquals(buffer1.take(read1), buffer2.take(read1))
623         }
624         assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1")
625     }
626 
627     /**
628      * Activity that reads a file specified as [Uri] in its start [Intent], and displays the file
629      * contents on screen by reading the file as UTF-8 text.
630      *
631      * The activity is registered in the manifest as a receiver for VIEW intents with a
632      * ".testtxtfile" URI.
633      */
634     class OpenTextFileActivity : Activity() {
onCreatenull635         override fun onCreate(savedInstanceState: Bundle?) {
636             super.onCreate(savedInstanceState)
637 
638             val testFile = intent.data ?: fail("This activity expects a file")
639             val fileStream = contentResolver.openInputStream(testFile)
640                     ?: fail("Could not open file InputStream")
641             val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use {
642                 it.readText()
643             }
644 
645             val view = TextView(this)
646             view.text = contents
647             setContentView(view)
648         }
649     }
650 }
651