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