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