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.Intent 21 import android.net.Network 22 import android.net.Uri 23 import android.os.Bundle 24 import android.os.Parcel 25 import android.os.Parcelable 26 import android.webkit.MimeTypeMap 27 import android.widget.TextView 28 import androidx.core.content.FileProvider 29 import androidx.test.core.app.ActivityScenario 30 import androidx.test.ext.junit.runners.AndroidJUnit4 31 import androidx.test.filters.SmallTest 32 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 33 import androidx.test.uiautomator.By 34 import androidx.test.uiautomator.UiDevice 35 import androidx.test.uiautomator.UiObject 36 import androidx.test.uiautomator.UiScrollable 37 import androidx.test.uiautomator.UiSelector 38 import androidx.test.uiautomator.Until 39 import com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn 40 import org.junit.Before 41 import org.junit.BeforeClass 42 import org.junit.Test 43 import org.junit.runner.RunWith 44 import org.mockito.Mockito.doReturn 45 import org.mockito.Mockito.mock 46 import org.mockito.Mockito.timeout 47 import org.mockito.Mockito.verify 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.text.NumberFormat 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 67 private val TEST_FILESIZE = 1_000_000 // 1MB 68 private val TEST_USERAGENT = "Test UserAgent" 69 private val TEST_URL = "https://test.download.example.com/myfile" 70 private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller" 71 72 // Test text file registered in the test manifest to be opened by a test activity 73 private val TEST_TEXT_FILE_EXTENSION = "testtxtfile" 74 private val TEST_TEXT_FILE_TYPE = "text/vnd.captiveportallogin.testtxtfile" 75 76 private val TEST_TIMEOUT_MS = 10_000L 77 // Timeout for notifications before trying to find it via scrolling 78 private val NOTIFICATION_NO_SCROLL_TIMEOUT_MS = 1000L 79 80 // Maximum number of scrolls from the top to attempt to find notifications in the notification shade 81 private val NOTIFICATION_SCROLL_COUNT = 30 82 // Swipe in a vertically centered area of 20% of the screen height (40% margin 83 // top/down): small swipes on notifications avoid dismissing the notification shade 84 private val NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT = .4 85 // Steps for each scroll in the notification shade (controls the scrolling speed). 86 // Each scroll is a series of cursor moves between multiple points on a line. The delay between each 87 // point is hard-coded, so the number of points (steps) controls how long the scroll takes. 88 private val NOTIFICATION_SCROLL_STEPS = 5 89 private val NOTIFICATION_SCROLL_POLL_MS = 100L 90 91 @RunWith(AndroidJUnit4::class) 92 @SmallTest 93 class DownloadServiceTest { 94 companion object { 95 @BeforeClass @JvmStatic 96 fun setUpClass() { 97 // Turn the MimeTypeMap for the process into a spy so test mimetypes can be added 98 val mimetypeMap = MimeTypeMap.getSingleton() 99 spyOn(mimetypeMap) 100 // Use a custom mimetype for the test to avoid cases where the device already has 101 // an app installed that can handle the detected mimetype (would be 102 // application/octet-stream by default for unusual extensions), which would cause the 103 // device to show a dialog to choose the app to use, and make it difficult to test. 104 doReturn(true).`when`(mimetypeMap).hasExtension(TEST_TEXT_FILE_EXTENSION) 105 doReturn(TEST_TEXT_FILE_TYPE).`when`(mimetypeMap).getMimeTypeFromExtension( 106 TEST_TEXT_FILE_EXTENSION) 107 } 108 } 109 110 private val connection = mock(HttpURLConnection::class.java) 111 112 private val context by lazy { getInstrumentation().context } 113 private val resources by lazy { context.resources } 114 private val device by lazy { UiDevice.getInstance(getInstrumentation()) } 115 116 // Test network that can be parceled in intents while mocking the connection 117 class TestNetwork(private val privateDnsBypass: Boolean = false) 118 : Network(43, privateDnsBypass) { 119 companion object { 120 // Subclasses of parcelable classes need to define a CREATOR field of their own (which 121 // hides the one of the parent class), otherwise the CREATOR field of the parent class 122 // would be used when unparceling and createFromParcel would return an instance of the 123 // parent class. 124 @JvmField 125 val CREATOR = object : Parcelable.Creator<TestNetwork> { 126 override fun createFromParcel(source: Parcel?) = TestNetwork() 127 override fun newArray(size: Int) = emptyArray<TestNetwork>() 128 } 129 130 /** 131 * Test [URLConnection] to be returned by all [TestNetwork] instances when 132 * [openConnection] is called. 133 * 134 * This can be set to a mock connection, and is a static to allow [TestNetwork]s to be 135 * parceled and unparceled without losing their mock configuration. 136 */ 137 internal var sTestConnection: HttpURLConnection? = null 138 } 139 140 override fun getPrivateDnsBypassingCopy(): Network { 141 // Note that the privateDnsBypass flag is not kept when parceling/unparceling: this 142 // mirrors the real behavior of that flag in Network. 143 // The test relies on this to verify that after setting privateDnsBypass to true, 144 // the TestNetwork is not parceled / unparceled, which would clear the flag both 145 // for TestNetwork or for a real Network and be a bug. 146 return TestNetwork(privateDnsBypass = true) 147 } 148 149 override fun openConnection(url: URL?): URLConnection { 150 // Verify that this network was created with privateDnsBypass = true, and was not 151 // parceled / unparceled afterwards (which would have cleared the flag). 152 assertTrue(privateDnsBypass, 153 "Captive portal downloads should be done on a network bypassing private DNS") 154 return sTestConnection ?: throw IllegalStateException( 155 "Mock URLConnection not initialized") 156 } 157 } 158 159 /** 160 * A test InputStream returning generated data. 161 * 162 * Reading this stream is not thread-safe: it should only be read by one thread at a time. 163 */ 164 private class TestInputStream(private var available: Int = 0) : InputStream() { 165 // position / available are only accessed in the reader thread 166 private var position = 0 167 168 private val nextAvailableQueue = SynchronousQueue<Int>() 169 170 /** 171 * Set how many bytes are available now without blocking. 172 * 173 * This is to be set on a thread controlling the amount of data that is available, while 174 * a reader thread may be trying to read the data. 175 * 176 * The reader thread will block until this value is increased, and if the reader is not yet 177 * waiting for the data to be made available, this method will block until it is. 178 */ 179 fun setAvailable(newAvailable: Int) { 180 assertTrue(nextAvailableQueue.offer(newAvailable.coerceIn(0, TEST_FILESIZE), 181 TEST_TIMEOUT_MS, MILLISECONDS), 182 "Timed out waiting for TestInputStream to be read") 183 } 184 185 override fun read(): Int { 186 throw NotImplementedError("read() should be unused") 187 } 188 189 /** 190 * Attempt to read [len] bytes at offset [off]. 191 * 192 * This will block until some data is available if no data currently is (so this method 193 * never returns 0 if [len] > 0). 194 */ 195 override fun read(b: ByteArray, off: Int, len: Int): Int { 196 if (position >= TEST_FILESIZE) return -1 // End of stream 197 198 while (available <= position) { 199 available = nextAvailableQueue.take() 200 } 201 202 // Read the requested bytes (but not more than available). 203 val remaining = available - position 204 val readLen = min(len, remaining) 205 for (i in 0 until readLen) { 206 b[off + i] = (position % 256).toByte() 207 position++ 208 } 209 210 return readLen 211 } 212 } 213 214 @Before 215 fun setUp() { 216 TestNetwork.sTestConnection = connection 217 218 doReturn(200).`when`(connection).responseCode 219 doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong 220 221 ActivityScenario.launch(RequestDismissKeyguardActivity::class.java) 222 } 223 224 /** 225 * Create a temporary, empty file that can be used to read/write data for testing. 226 */ 227 private fun createTestFile(extension: String = ".png"): File { 228 // temp/ is as exported in file_paths.xml, so that the file can be shared externally 229 // (in the download success notification) 230 val testFilePath = File(context.getCacheDir(), "temp") 231 testFilePath.mkdir() 232 // Do not use File.createTempFile, as it generates very long filenames that may not 233 // fit in notifications, making it difficult to find the right notification. 234 // currentTimeMillis would generally be 13 digits. Use the bottom 8 to fit the filename and 235 // a bit more text, even on very small screens (320 dp, minimum CDD size). 236 var index = System.currentTimeMillis().rem(100_000_000) 237 while (true) { 238 val file = File(testFilePath, "tmp$index$extension") 239 if (!file.exists()) { 240 file.createNewFile() 241 return file 242 } 243 index++ 244 } 245 } 246 247 private fun makeDownloadIntent(testFile: File) = DownloadService.makeDownloadIntent( 248 context, 249 TestNetwork(), 250 TEST_USERAGENT, 251 TEST_URL, 252 testFile.name, 253 makeFileUri(testFile)) 254 255 /** 256 * Make a file URI based on a file on disk, using a [FileProvider] that is registered for the 257 * test app. 258 */ 259 private fun makeFileUri(testFile: File) = FileProvider.getUriForFile( 260 context, 261 // File provider registered in the test manifest 262 "com.android.captiveportallogin.tests.fileprovider", 263 testFile) 264 265 @Test 266 fun testDownloadFile() { 267 val inputStream1 = TestInputStream() 268 doReturn(inputStream1).`when`(connection).inputStream 269 270 val testFile1 = createTestFile() 271 val testFile2 = createTestFile() 272 assertNotEquals(testFile1.name, testFile2.name) 273 val downloadIntent1 = makeDownloadIntent(testFile1) 274 val downloadIntent2 = makeDownloadIntent(testFile2) 275 openNotificationShade() 276 277 // Queue both downloads immediately: they should be started in order 278 context.startForegroundService(downloadIntent1) 279 context.startForegroundService(downloadIntent2) 280 281 verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream 282 val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name) 283 284 findNotification(UiSelector().textContains(dlText1)) 285 286 // Allow download to progress to 1% 287 assertEquals(0, TEST_FILESIZE % 100) 288 assertTrue(TEST_FILESIZE / 100 > 0) 289 inputStream1.setAvailable(TEST_FILESIZE / 100) 290 291 // 1% progress should be shown in the notification 292 val progressText = NumberFormat.getPercentInstance().format(.01f) 293 findNotification(UiSelector().textContains(progressText)) 294 295 // Setup the connection for the next download with indeterminate progress 296 val inputStream2 = TestInputStream() 297 doReturn(inputStream2).`when`(connection).inputStream 298 doReturn(-1L).`when`(connection).contentLengthLong 299 300 // Allow the first download to finish 301 inputStream1.setAvailable(TEST_FILESIZE) 302 verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect() 303 304 FileInputStream(testFile1).use { 305 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 306 } 307 308 testFile1.delete() 309 310 // The second download should have started: make some data available 311 inputStream2.setAvailable(TEST_FILESIZE / 100) 312 313 // A notification should be shown for the second download with indeterminate progress 314 val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name) 315 findNotification(UiSelector().textContains(dlText2)) 316 317 // Allow the second download to finish 318 inputStream2.setAvailable(TEST_FILESIZE) 319 verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect() 320 321 FileInputStream(testFile2).use { 322 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 323 } 324 325 testFile2.delete() 326 } 327 328 @Test 329 fun testTapDoneNotification() { 330 val fileContents = "Test file contents" 331 val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8)) 332 doReturn(bis).`when`(connection).inputStream 333 334 // The test extension is handled by OpenTextFileActivity in the test package 335 val testFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION") 336 val downloadIntent = makeDownloadIntent(testFile) 337 openNotificationShade() 338 339 context.startForegroundService(downloadIntent) 340 341 // The download completed notification has the filename as contents, and 342 // R.string.download_completed as title. Find the contents using the filename as exact match 343 val note = findNotification(UiSelector().text(testFile.name)) 344 note.click() 345 346 // OpenTextFileActivity opens the file and shows contents 347 assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS)) 348 } 349 350 private fun openNotificationShade() { 351 device.wakeUp() 352 device.openNotification() 353 assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS)) 354 } 355 356 private fun findNotification(selector: UiSelector): UiObject { 357 val shadeScroller = UiScrollable(UiSelector().resourceId(NOTIFICATION_SHADE_TYPE)) 358 .setSwipeDeadZonePercentage(NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT) 359 360 // Optimistically wait for the notification without scrolling (scrolling is slow) 361 val note = shadeScroller.getChild(selector) 362 if (note.waitForExists(NOTIFICATION_NO_SCROLL_TIMEOUT_MS)) return note 363 364 val limit = System.currentTimeMillis() + TEST_TIMEOUT_MS 365 while (System.currentTimeMillis() < limit) { 366 // Similar to UiScrollable.scrollIntoView, but do not scroll up before going down (it 367 // could open the quick settings), and control the scroll steps (with a large swipe 368 // dead zone, scrollIntoView uses too many steps by default and is very slow). 369 for (i in 0 until NOTIFICATION_SCROLL_COUNT) { 370 val canScrollFurther = shadeScroller.scrollForward(NOTIFICATION_SCROLL_STEPS) 371 if (note.exists()) return note 372 // Scrolled to the end, or scrolled too much and closed the shade 373 if (!canScrollFurther || !shadeScroller.exists()) break 374 } 375 376 // Go back to the top: close then reopen the notification shade. 377 // Do not scroll up, as it could open quick settings (and would be slower). 378 device.pressHome() 379 assertTrue(shadeScroller.waitUntilGone(TEST_TIMEOUT_MS)) 380 openNotificationShade() 381 382 Thread.sleep(NOTIFICATION_SCROLL_POLL_MS) 383 } 384 fail("Notification with selector $selector not found") 385 } 386 387 /** 388 * Verify that two [InputStream] have the same content by reading them until the end of stream. 389 */ 390 private fun assertSameContents(s1: InputStream, s2: InputStream) { 391 val buffer1 = ByteArray(1000) 392 val buffer2 = ByteArray(1000) 393 while (true) { 394 // Read one chunk from s1 395 val read1 = s1.read(buffer1, 0, buffer1.size) 396 if (read1 < 0) break 397 398 // Read a chunk of the same size from s2 399 var read2 = 0 400 while (read2 < read1) { 401 s2.read(buffer2, read2, read1 - read2).also { 402 assertFalse(it < 0, "Stream 2 is shorter than stream 1") 403 read2 += it 404 } 405 } 406 assertEquals(buffer1.take(read1), buffer2.take(read1)) 407 } 408 assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1") 409 } 410 411 /** 412 * Activity that reads a file specified as [Uri] in its start [Intent], and displays the file 413 * contents on screen by reading the file as UTF-8 text. 414 * 415 * The activity is registered in the manifest as a receiver for VIEW intents with a 416 * ".testtxtfile" URI. 417 */ 418 class OpenTextFileActivity : Activity() { 419 override fun onCreate(savedInstanceState: Bundle?) { 420 super.onCreate(savedInstanceState) 421 422 val testFile = intent.data ?: fail("This activity expects a file") 423 val fileStream = contentResolver.openInputStream(testFile) 424 ?: fail("Could not open file InputStream") 425 val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use { 426 it.readText() 427 } 428 429 val view = TextView(this) 430 view.text = contents 431 setContentView(view) 432 } 433 } 434 } 435