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.IOException 47 import java.io.InputStream 48 import java.io.InputStreamReader 49 import java.net.HttpURLConnection 50 import java.net.URL 51 import java.net.URLConnection 52 import java.nio.charset.StandardCharsets 53 import java.text.NumberFormat 54 import java.util.concurrent.SynchronousQueue 55 import java.util.concurrent.TimeUnit.MILLISECONDS 56 import kotlin.math.min 57 import kotlin.test.assertEquals 58 import kotlin.test.assertFalse 59 import kotlin.test.assertNotEquals 60 import kotlin.test.assertNotNull 61 import kotlin.test.assertTrue 62 import kotlin.test.fail 63 64 private val TEST_FILESIZE = 1_000_000 // 1MB 65 private val TEST_USERAGENT = "Test UserAgent" 66 private val TEST_URL = "https://test.download.example.com/myfile" 67 private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller" 68 69 private val TEST_TIMEOUT_MS = 10_000L 70 71 @RunWith(AndroidJUnit4::class) 72 @SmallTest 73 class DownloadServiceTest { 74 private val connection = mock(HttpURLConnection::class.java) 75 <lambda>null76 private val context by lazy { getInstrumentation().context } <lambda>null77 private val resources by lazy { context.resources } <lambda>null78 private val device by lazy { UiDevice.getInstance(getInstrumentation()) } 79 80 // Test network that can be parceled in intents while mocking the connection 81 class TestNetwork : Network(43) { 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 openConnection(url: URL?): URLConnection { 104 return sTestConnection ?: throw IOException("Mock URLConnection not initialized") 105 } 106 } 107 108 /** 109 * A test InputStream returning generated data. 110 * 111 * Reading this stream is not thread-safe: it should only be read by one thread at a time. 112 */ 113 private class TestInputStream(private var available: Int = 0) : InputStream() { 114 // position / available are only accessed in the reader thread 115 private var position = 0 116 117 private val nextAvailableQueue = SynchronousQueue<Int>() 118 119 /** 120 * Set how many bytes are available now without blocking. 121 * 122 * This is to be set on a thread controlling the amount of data that is available, while 123 * a reader thread may be trying to read the data. 124 * 125 * The reader thread will block until this value is increased, and if the reader is not yet 126 * waiting for the data to be made available, this method will block until it is. 127 */ setAvailablenull128 fun setAvailable(newAvailable: Int) { 129 assertTrue(nextAvailableQueue.offer(newAvailable.coerceIn(0, TEST_FILESIZE), 130 TEST_TIMEOUT_MS, MILLISECONDS), 131 "Timed out waiting for TestInputStream to be read") 132 } 133 readnull134 override fun read(): Int { 135 throw NotImplementedError("read() should be unused") 136 } 137 138 /** 139 * Attempt to read [len] bytes at offset [off]. 140 * 141 * This will block until some data is available if no data currently is (so this method 142 * never returns 0 if [len] > 0). 143 */ readnull144 override fun read(b: ByteArray, off: Int, len: Int): Int { 145 if (position >= TEST_FILESIZE) return -1 // End of stream 146 147 while (available <= position) { 148 available = nextAvailableQueue.take() 149 } 150 151 // Read the requested bytes (but not more than available). 152 val remaining = available - position 153 val readLen = min(len, remaining) 154 for (i in 0 until readLen) { 155 b[off + i] = (position % 256).toByte() 156 position++ 157 } 158 159 return readLen 160 } 161 } 162 163 @Before setUpnull164 fun setUp() { 165 TestNetwork.sTestConnection = connection 166 167 doReturn(200).`when`(connection).responseCode 168 doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong 169 170 ActivityScenario.launch(RequestDismissKeyguardActivity::class.java) 171 } 172 173 /** 174 * Create a temporary, empty file that can be used to read/write data for testing. 175 */ createTestFilenull176 private fun createTestFile(extension: String = ".png"): File { 177 // temp/ is as exported in file_paths.xml, so that the file can be shared externally 178 // (in the download success notification) 179 val testFilePath = File(context.getCacheDir(), "temp") 180 testFilePath.mkdir() 181 return File.createTempFile("test", extension, testFilePath) 182 } 183 makeDownloadIntentnull184 private fun makeDownloadIntent(testFile: File) = DownloadService.makeDownloadIntent( 185 context, 186 TestNetwork(), 187 TEST_USERAGENT, 188 TEST_URL, 189 testFile.name, 190 makeFileUri(testFile)) 191 192 /** 193 * Make a file URI based on a file on disk, using a [FileProvider] that is registered for the 194 * test app. 195 */ 196 private fun makeFileUri(testFile: File) = FileProvider.getUriForFile( 197 context, 198 // File provider registered in the test manifest 199 "com.android.captiveportallogin.tests.fileprovider", 200 testFile) 201 202 @Test 203 fun testDownloadFile() { 204 val inputStream1 = TestInputStream() 205 doReturn(inputStream1).`when`(connection).inputStream 206 207 val testFile1 = createTestFile() 208 val testFile2 = createTestFile() 209 assertNotEquals(testFile1.name, testFile2.name) 210 val downloadIntent1 = makeDownloadIntent(testFile1) 211 val downloadIntent2 = makeDownloadIntent(testFile2) 212 openNotificationShade() 213 214 // Queue both downloads immediately: they should be started in order 215 context.startForegroundService(downloadIntent1) 216 context.startForegroundService(downloadIntent2) 217 218 verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream 219 val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name) 220 221 assertTrue(device.wait(Until.hasObject( 222 By.res(NOTIFICATION_SHADE_TYPE).hasDescendant(By.text(dlText1))), TEST_TIMEOUT_MS)) 223 224 // Allow download to progress to 1% 225 assertEquals(0, TEST_FILESIZE % 100) 226 assertTrue(TEST_FILESIZE / 100 > 0) 227 inputStream1.setAvailable(TEST_FILESIZE / 100) 228 229 // 1% progress should be shown in the notification 230 assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE).hasDescendant( 231 By.text(NumberFormat.getPercentInstance().format(.01f)))), TEST_TIMEOUT_MS)) 232 233 // Setup the connection for the next download with indeterminate progress 234 val inputStream2 = TestInputStream() 235 doReturn(inputStream2).`when`(connection).inputStream 236 doReturn(-1L).`when`(connection).contentLengthLong 237 238 // Allow the first download to finish 239 inputStream1.setAvailable(TEST_FILESIZE) 240 verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect() 241 242 FileInputStream(testFile1).use { 243 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 244 } 245 246 testFile1.delete() 247 248 // The second download should have started: make some data available 249 inputStream2.setAvailable(TEST_FILESIZE / 100) 250 251 // A notification should be shown for the second download with indeterminate progress 252 val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name) 253 assertTrue(device.wait(Until.hasObject( 254 By.res(NOTIFICATION_SHADE_TYPE).hasDescendant(By.text(dlText2))), TEST_TIMEOUT_MS)) 255 256 // Allow the second download to finish 257 inputStream2.setAvailable(TEST_FILESIZE) 258 verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect() 259 260 FileInputStream(testFile2).use { 261 assertSameContents(it, TestInputStream(TEST_FILESIZE)) 262 } 263 264 testFile2.delete() 265 } 266 267 @Test testTapDoneNotificationnull268 fun testTapDoneNotification() { 269 val fileContents = "Test file contents" 270 val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8)) 271 doReturn(bis).`when`(connection).inputStream 272 273 // .testtxtfile extension is handled by OpenTextFileActivity in the test package 274 val testFile = createTestFile(extension = ".testtxtfile") 275 val downloadIntent = makeDownloadIntent(testFile) 276 openNotificationShade() 277 278 context.startForegroundService(downloadIntent) 279 280 val doneText = resources.getString(R.string.download_completed) 281 val note = device.wait(Until.findObject(By.text(doneText)), TEST_TIMEOUT_MS) 282 assertNotNull(note, "Notification with text \"$doneText\" not found") 283 284 note.click() 285 286 // OpenTextFileActivity opens the file and shows contents 287 assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS)) 288 } 289 openNotificationShadenull290 private fun openNotificationShade() { 291 device.wakeUp() 292 device.openNotification() 293 assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS)) 294 } 295 296 /** 297 * Verify that two [InputStream] have the same content by reading them until the end of stream. 298 */ assertSameContentsnull299 private fun assertSameContents(s1: InputStream, s2: InputStream) { 300 val buffer1 = ByteArray(1000) 301 val buffer2 = ByteArray(1000) 302 while (true) { 303 // Read one chunk from s1 304 val read1 = s1.read(buffer1, 0, buffer1.size) 305 if (read1 < 0) break 306 307 // Read a chunk of the same size from s2 308 var read2 = 0 309 while (read2 < read1) { 310 s2.read(buffer2, read2, read1 - read2).also { 311 assertFalse(it < 0, "Stream 2 is shorter than stream 1") 312 read2 += it 313 } 314 } 315 assertEquals(buffer1.take(read1), buffer2.take(read1)) 316 } 317 assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1") 318 } 319 320 /** 321 * [KeyguardManager.requestDismissKeyguard] requires an activity: this activity allows the test 322 * to dismiss the keyguard by just being started. 323 */ 324 class RequestDismissKeyguardActivity : Activity() { onCreatenull325 override fun onCreate(savedInstanceState: Bundle?) { 326 super.onCreate(savedInstanceState) 327 getSystemService(KeyguardManager::class.java).requestDismissKeyguard(this, null) 328 } 329 } 330 331 /** 332 * Activity that reads a file specified as [Uri] in its start [Intent], and displays the file 333 * contents on screen by reading the file as UTF-8 text. 334 * 335 * The activity is registered in the manifest as a receiver for VIEW intents with a 336 * ".testtxtfile" URI. 337 */ 338 class OpenTextFileActivity : Activity() { onCreatenull339 override fun onCreate(savedInstanceState: Bundle?) { 340 super.onCreate(savedInstanceState) 341 342 val testFile = intent.data ?: fail("This activity expects a file") 343 val fileStream = contentResolver.openInputStream(testFile) 344 ?: fail("Could not open file InputStream") 345 val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use { 346 it.readText() 347 } 348 349 val view = TextView(this) 350 view.text = contents 351 setContentView(view) 352 } 353 } 354 }