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