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