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