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 }