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 }