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.app.KeyguardManager 21 import android.content.Intent 22 import android.net.Network 23 import android.net.Uri 24 import android.os.Bundle 25 import android.os.Parcel 26 import android.os.Parcelable 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.Until 36 import org.junit.Before 37 import org.junit.Test 38 import org.junit.runner.RunWith 39 import org.mockito.Mockito.doReturn 40 import org.mockito.Mockito.mock 41 import org.mockito.Mockito.timeout 42 import org.mockito.Mockito.verify 43 import java.io.ByteArrayInputStream 44 import java.io.File 45 import java.io.FileInputStream 46 import java.io.InputStream 47 import java.io.InputStreamReader 48 import java.net.HttpURLConnection 49 import java.net.URL 50 import java.net.URLConnection 51 import java.nio.charset.StandardCharsets 52 import java.text.NumberFormat 53 import java.util.concurrent.SynchronousQueue 54 import java.util.concurrent.TimeUnit.MILLISECONDS 55 import kotlin.math.min 56 import kotlin.test.assertEquals 57 import kotlin.test.assertFalse 58 import kotlin.test.assertNotEquals 59 import kotlin.test.assertNotNull 60 import kotlin.test.assertTrue 61 import kotlin.test.fail 62 63 private val TEST_FILESIZE = 1_000_000 // 1MB 64 private val TEST_USERAGENT = "Test UserAgent" 65 private val TEST_URL = "https://test.download.example.com/myfile" 66 private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller" 67 68 private val TEST_TIMEOUT_MS = 10_000L 69 70 @RunWith(AndroidJUnit4::class) 71 @SmallTest 72 class DownloadServiceTest { 73 private val connection = mock(HttpURLConnection::class.java) 74 <lambda>null75 private val context by lazy { getInstrumentation().context } <lambda>null76 private val resources by lazy { context.resources } <lambda>null77 private val device by lazy { UiDevice.getInstance(getInstrumentation()) } 78 79 // Test network that can be parceled in intents while mocking the connection 80 class TestNetwork(private val privateDnsBypass: Boolean = false) 81 : Network(43, privateDnsBypass) { 82 companion object { 83 // Subclasses of parcelable classes need to define a CREATOR field of their own (which 84 // hides the one of the parent class), otherwise the CREATOR field of the parent class 85 // would be used when unparceling and createFromParcel would return an instance of the 86 // parent class. 87 @JvmField 88 val CREATOR = object : Parcelable.Creator<TestNetwork> { createFromParcelnull89 override fun createFromParcel(source: Parcel?) = TestNetwork() 90 override fun newArray(size: Int) = emptyArray<TestNetwork>() 91 } 92 93 /** 94 * Test [URLConnection] to be returned by all [TestNetwork] instances when 95 * [openConnection] is called. 96 * 97 * This can be set to a mock connection, and is a static to allow [TestNetwork]s to be 98 * parceled and unparceled without losing their mock configuration. 99 */ 100 internal var sTestConnection: HttpURLConnection? = null 101 } 102 103 override fun getPrivateDnsBypassingCopy(): Network { 104 // Note that the privateDnsBypass flag is not kept when parceling/unparceling: this 105 // mirrors the real behavior of that flag in Network. 106 // The test relies on this to verify that after setting privateDnsBypass to true, 107 // the TestNetwork is not parceled / unparceled, which would clear the flag both 108 // for TestNetwork or for a real Network and be a bug. 109 return TestNetwork(privateDnsBypass = true) 110 } 111 openConnectionnull112 override fun openConnection(url: URL?): URLConnection { 113 // Verify that this network was created with privateDnsBypass = true, and was not 114 // parceled / unparceled afterwards (which would have cleared the flag). 115 assertTrue(privateDnsBypass, 116 "Captive portal downloads should be done on a network bypassing private DNS") 117 return sTestConnection ?: throw IllegalStateException( 118 "Mock URLConnection not initialized") 119 } 120 } 121 122 /** 123 * A test InputStream returning generated data. 124 * 125 * Reading this stream is not thread-safe: it should only be read by one thread at a time. 126 */ 127 private class TestInputStream(private var available: Int = 0) : InputStream() { 128 // position / available are only accessed in the reader thread 129 private var position = 0 130 131 private val nextAvailableQueue = SynchronousQueue<Int>() 132 133 /** 134 * Set how many bytes are available now without blocking. 135 * 136 * This is to be set on a thread controlling the amount of data that is available, while 137 * a reader thread may be trying to read the data. 138 * 139 * The reader thread will block until this value is increased, and if the reader is not yet 140 * waiting for the data to be made available, this method will block until it is. 141 */ setAvailablenull142 fun setAvailable(newAvailable: Int) { 143 assertTrue(nextAvailableQueue.offer(newAvailable.coerceIn(0, TEST_FILESIZE), 144 TEST_TIMEOUT_MS, MILLISECONDS), 145 "Timed out waiting for TestInputStream to be read") 146 } 147 readnull148 override fun read(): Int { 149 throw NotImplementedError("read() should be unused") 150 } 151 152 /** 153 * Attempt to read [len] bytes at offset [off]. 154 * 155 * This will block until some data is available if no data currently is (so this method 156 * never returns 0 if [len] > 0). 157 */ readnull158 override fun read(b: ByteArray, off: Int, len: Int): Int { 159 if (position >= TEST_FILESIZE) return -1 // End of stream 160 161 while (available <= position) { 162 available = nextAvailableQueue.take() 163 } 164 165 // Read the requested bytes (but not more than available). 166 val remaining = available - position 167 val readLen = min(len, remaining) 168 for (i in 0 until readLen) { 169 b[off + i] = (position % 256).toByte() 170 position++ 171 } 172 173 return readLen 174 } 175 } 176 177 @Before setUpnull178 fun setUp() { 179 TestNetwork.sTestConnection = connection 180 181 doReturn(200).`when`(connection).responseCode 182 doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong 183 184 ActivityScenario.launch(RequestDismissKeyguardActivity::class.java) 185 } 186 187 /** 188 * Create a temporary, empty file that can be used to read/write data for testing. 189 */ createTestFilenull190 private fun createTestFile(extension: String = ".png"): File { 191 // temp/ is as exported in file_paths.xml, so that the file can be shared externally 192 // (in the download success notification) 193 val testFilePath = File(context.getCacheDir(), "temp") 194 testFilePath.mkdir() 195 return File.createTempFile("test", extension, testFilePath) 196 } 197 makeDownloadIntentnull198 private fun makeDownloadIntent(testFile: File) = DownloadService.makeDownloadIntent( 199 context, 200 TestNetwork(), 201 TEST_USERAGENT, 202 TEST_URL, 203 testFile.name, 204 makeFileUri(testFile)) 205 206 /** 207 * Make a file URI based on a file on disk, using a [FileProvider] that is registered for the 208 * test app. 209 */ 210 private fun makeFileUri(testFile: File) = FileProvider.getUriForFile( 211 context, 212 // File provider registered in the test manifest 213 "com.android.captiveportallogin.tests.fileprovider", 214 testFile) 215 216 @Test 217 fun testDownloadFile() { 218 val inputStream1 = TestInputStream() 219 doReturn(inputStream1).`when`(connection).inputStream 220 221 val testFile1 = createTestFile() 222 val testFile2 = createTestFile() 223 assertNotEquals(testFile1.name, testFile2.name) 224 val downloadIntent1 = makeDownloadIntent(testFile1) 225 val downloadIntent2 = makeDownloadIntent(testFile2) 226 openNotificationShade() 227 228 // Queue both downloads immediately: they should be started in order 229 context.startForegroundService(downloadIntent1) 230 context.startForegroundService(downloadIntent2) 231 232 verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream 233 val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name) 234 235 assertTrue(device.wait(Until.hasObject( 236 By.res(NOTIFICATION_SHADE_TYPE).hasDescendant(By.text(dlText1))), TEST_TIMEOUT_MS)) 237 238 // Allow download to progress to 1% 239 assertEquals(0, TEST_FILESIZE % 100) 240 assertTrue(TEST_FILESIZE / 100 > 0) 241 inputStream1.setAvailable(TEST_FILESIZE / 100) 242 243 // 1% progress should be shown in the notification 244 assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE).hasDescendant( 245 By.text(NumberFormat.getPercentInstance().format(.01f)))), TEST_TIMEOUT_MS)) 246 247 // Setup the connection for the next download with indeterminate progress 248 val inputStream2 = TestInputStream() 249 doReturn(inputStream2).`when`(connection).inputStream 250 doReturn(-1L).`when`(connection).contentLengthLong 251 252 // Allow the first download to finish 253 inputStream1.setAvailable(TEST_FILESIZE) 254 verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect() 255 256 FileInputStream(testFile1).use { 257 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 258 } 259 260 testFile1.delete() 261 262 // The second download should have started: make some data available 263 inputStream2.setAvailable(TEST_FILESIZE / 100) 264 265 // A notification should be shown for the second download with indeterminate progress 266 val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name) 267 assertTrue(device.wait(Until.hasObject( 268 By.res(NOTIFICATION_SHADE_TYPE).hasDescendant(By.text(dlText2))), TEST_TIMEOUT_MS)) 269 270 // Allow the second download to finish 271 inputStream2.setAvailable(TEST_FILESIZE) 272 verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect() 273 274 FileInputStream(testFile2).use { 275 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 276 } 277 278 testFile2.delete() 279 } 280 281 @Test testTapDoneNotificationnull282 fun testTapDoneNotification() { 283 val fileContents = "Test file contents" 284 val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8)) 285 doReturn(bis).`when`(connection).inputStream 286 287 // .testtxtfile extension is handled by OpenTextFileActivity in the test package 288 val testFile = createTestFile(extension = ".testtxtfile") 289 val downloadIntent = makeDownloadIntent(testFile) 290 openNotificationShade() 291 292 context.startForegroundService(downloadIntent) 293 294 val doneText = resources.getString(R.string.download_completed) 295 val note = device.wait(Until.findObject(By.text(doneText)), TEST_TIMEOUT_MS) 296 assertNotNull(note, "Notification with text \"$doneText\" not found") 297 298 note.click() 299 300 // OpenTextFileActivity opens the file and shows contents 301 assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS)) 302 } 303 openNotificationShadenull304 private fun openNotificationShade() { 305 device.wakeUp() 306 device.openNotification() 307 assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS)) 308 } 309 310 /** 311 * Verify that two [InputStream] have the same content by reading them until the end of stream. 312 */ assertSameContentsnull313 private fun assertSameContents(s1: InputStream, s2: InputStream) { 314 val buffer1 = ByteArray(1000) 315 val buffer2 = ByteArray(1000) 316 while (true) { 317 // Read one chunk from s1 318 val read1 = s1.read(buffer1, 0, buffer1.size) 319 if (read1 < 0) break 320 321 // Read a chunk of the same size from s2 322 var read2 = 0 323 while (read2 < read1) { 324 s2.read(buffer2, read2, read1 - read2).also { 325 assertFalse(it < 0, "Stream 2 is shorter than stream 1") 326 read2 += it 327 } 328 } 329 assertEquals(buffer1.take(read1), buffer2.take(read1)) 330 } 331 assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1") 332 } 333 334 /** 335 * [KeyguardManager.requestDismissKeyguard] requires an activity: this activity allows the test 336 * to dismiss the keyguard by just being started. 337 */ 338 class RequestDismissKeyguardActivity : Activity() { onCreatenull339 override fun onCreate(savedInstanceState: Bundle?) { 340 super.onCreate(savedInstanceState) 341 getSystemService(KeyguardManager::class.java).requestDismissKeyguard(this, null) 342 } 343 } 344 345 /** 346 * Activity that reads a file specified as [Uri] in its start [Intent], and displays the file 347 * contents on screen by reading the file as UTF-8 text. 348 * 349 * The activity is registered in the manifest as a receiver for VIEW intents with a 350 * ".testtxtfile" URI. 351 */ 352 class OpenTextFileActivity : Activity() { onCreatenull353 override fun onCreate(savedInstanceState: Bundle?) { 354 super.onCreate(savedInstanceState) 355 356 val testFile = intent.data ?: fail("This activity expects a file") 357 val fileStream = contentResolver.openInputStream(testFile) 358 ?: fail("Could not open file InputStream") 359 val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use { 360 it.readText() 361 } 362 363 val view = TextView(this) 364 view.text = contents 365 setContentView(view) 366 } 367 } 368 }