• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.ComponentName
21 import android.content.Context
22 import android.content.Intent
23 import android.content.ServiceConnection
24 import android.net.Network
25 import android.net.Uri
26 import android.os.Bundle
27 import android.os.IBinder
28 import android.os.Parcel
29 import android.os.Parcelable
30 import android.webkit.MimeTypeMap
31 import android.widget.TextView
32 import androidx.core.content.FileProvider
33 import androidx.test.core.app.ActivityScenario
34 import androidx.test.ext.junit.runners.AndroidJUnit4
35 import androidx.test.filters.SmallTest
36 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
37 import androidx.test.rule.ServiceTestRule
38 import androidx.test.uiautomator.By
39 import androidx.test.uiautomator.UiDevice
40 import androidx.test.uiautomator.UiObject
41 import androidx.test.uiautomator.UiScrollable
42 import androidx.test.uiautomator.UiSelector
43 import androidx.test.uiautomator.Until
44 import com.android.captiveportallogin.DownloadService.DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE
45 import com.android.captiveportallogin.DownloadService.DownloadServiceBinder
46 import com.android.captiveportallogin.DownloadService.ProgressCallback
47 import com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn
48 import com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn
49 import java.io.ByteArrayInputStream
50 import java.io.File
51 import java.io.FileInputStream
52 import java.io.InputStream
53 import java.io.InputStreamReader
54 import java.net.HttpURLConnection
55 import java.net.URL
56 import java.net.URLConnection
57 import java.nio.charset.StandardCharsets
58 import java.util.concurrent.CompletableFuture
59 import java.util.concurrent.SynchronousQueue
60 import java.util.concurrent.TimeUnit.MILLISECONDS
61 import kotlin.math.min
62 import kotlin.test.assertEquals
63 import kotlin.test.assertFalse
64 import kotlin.test.assertNotEquals
65 import kotlin.test.assertTrue
66 import kotlin.test.fail
67 import org.junit.Assert.assertNotNull
68 import org.junit.Before
69 import org.junit.BeforeClass
70 import org.junit.Rule
71 import org.junit.Test
72 import org.junit.runner.RunWith
73 import org.mockito.Mockito.mock
74 import org.mockito.Mockito.timeout
75 import org.mockito.Mockito.verify
76 
77 private val TEST_FILESIZE = 1_000_000 // 1MB
78 private val TEST_USERAGENT = "Test UserAgent"
79 private val TEST_URL = "https://test.download.example.com/myfile"
80 private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller"
81 
82 // Test text file registered in the test manifest to be opened by a test activity
83 private val TEST_TEXT_FILE_EXTENSION = "testtxtfile"
84 private val TEST_TEXT_FILE_TYPE = "text/vnd.captiveportallogin.testtxtfile"
85 
86 private val TEST_TIMEOUT_MS = 10_000L
87 // Timeout for notifications before trying to find it via scrolling
88 private val NOTIFICATION_NO_SCROLL_TIMEOUT_MS = 1000L
89 
90 // Maximum number of scrolls from the top to attempt to find notifications in the notification shade
91 private val NOTIFICATION_SCROLL_COUNT = 30
92 // Swipe in a vertically centered area of 20% of the screen height (40% margin
93 // top/down): small swipes on notifications avoid dismissing the notification shade
94 private val NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT = .4
95 // Steps for each scroll in the notification shade (controls the scrolling speed).
96 // Each scroll is a series of cursor moves between multiple points on a line. The delay between each
97 // point is hard-coded, so the number of points (steps) controls how long the scroll takes.
98 private val NOTIFICATION_SCROLL_STEPS = 5
99 private val NOTIFICATION_SCROLL_POLL_MS = 100L
100 
101 private val TEST_WIFI_CONFIG_TYPE = "application/x-wifi-config"
102 
103 @Rule
104 val mServiceRule = ServiceTestRule()
105 
106 @RunWith(AndroidJUnit4::class)
107 @SmallTest
108 class DownloadServiceTest {
109     companion object {
110         @BeforeClass @JvmStatic
setUpClassnull111         fun setUpClass() {
112             // Turn the MimeTypeMap for the process into a spy so test mimetypes can be added
113             val mimetypeMap = MimeTypeMap.getSingleton()
114             spyOn(mimetypeMap)
115             // Use a custom mimetype for the test to avoid cases where the device already has
116             // an app installed that can handle the detected mimetype (would be
117             // application/octet-stream by default for unusual extensions), which would cause the
118             // device to show a dialog to choose the app to use, and make it difficult to test.
119             doReturn(true).`when`(mimetypeMap).hasExtension(TEST_TEXT_FILE_EXTENSION)
120             doReturn(TEST_TEXT_FILE_TYPE).`when`(mimetypeMap).getMimeTypeFromExtension(
121                     TEST_TEXT_FILE_EXTENSION)
122         }
123     }
124 
125     private val connection = mock(HttpURLConnection::class.java)
126 
<lambda>null127     private val context by lazy { getInstrumentation().context }
<lambda>null128     private val resources by lazy { context.resources }
<lambda>null129     private val device by lazy { UiDevice.getInstance(getInstrumentation()) }
130 
131     // Test network that can be parceled in intents while mocking the connection
132     class TestNetwork(private val privateDnsBypass: Boolean = false)
133         : Network(43, privateDnsBypass) {
134         companion object {
135             // Subclasses of parcelable classes need to define a CREATOR field of their own (which
136             // hides the one of the parent class), otherwise the CREATOR field of the parent class
137             // would be used when unparceling and createFromParcel would return an instance of the
138             // parent class.
139             @JvmField
140             val CREATOR = object : Parcelable.Creator<TestNetwork> {
createFromParcelnull141                 override fun createFromParcel(source: Parcel?) = TestNetwork()
142                 override fun newArray(size: Int) = emptyArray<TestNetwork>()
143             }
144 
145             /**
146              * Test [URLConnection] to be returned by all [TestNetwork] instances when
147              * [openConnection] is called.
148              *
149              * This can be set to a mock connection, and is a static to allow [TestNetwork]s to be
150              * parceled and unparceled without losing their mock configuration.
151              */
152             internal var sTestConnection: HttpURLConnection? = null
153         }
154 
155         override fun getPrivateDnsBypassingCopy(): Network {
156             // Note that the privateDnsBypass flag is not kept when parceling/unparceling: this
157             // mirrors the real behavior of that flag in Network.
158             // The test relies on this to verify that after setting privateDnsBypass to true,
159             // the TestNetwork is not parceled / unparceled, which would clear the flag both
160             // for TestNetwork or for a real Network and be a bug.
161             return TestNetwork(privateDnsBypass = true)
162         }
163 
openConnectionnull164         override fun openConnection(url: URL?): URLConnection {
165             // Verify that this network was created with privateDnsBypass = true, and was not
166             // parceled / unparceled afterwards (which would have cleared the flag).
167             assertTrue(privateDnsBypass,
168                     "Captive portal downloads should be done on a network bypassing private DNS")
169             return sTestConnection ?: throw IllegalStateException(
170                     "Mock URLConnection not initialized")
171         }
172     }
173 
174     /**
175      * A test InputStream returning generated data.
176      *
177      * Reading this stream is not thread-safe: it should only be read by one thread at a time.
178      */
179     private class TestInputStream(private var available: Int = 0) : InputStream() {
180         // position / available are only accessed in the reader thread
181         private var position = 0
182 
183         private val nextAvailableQueue = SynchronousQueue<Int>()
184 
185         /**
186          * Set how many bytes are available now without blocking.
187          *
188          * This is to be set on a thread controlling the amount of data that is available, while
189          * a reader thread may be trying to read the data.
190          *
191          * The reader thread will block until this value is increased, and if the reader is not yet
192          * waiting for the data to be made available, this method will block until it is.
193          */
setAvailablenull194         fun setAvailable(newAvailable: Int) {
195             assertTrue(nextAvailableQueue.offer(newAvailable.coerceIn(0, TEST_FILESIZE),
196                     TEST_TIMEOUT_MS, MILLISECONDS),
197                     "Timed out waiting for TestInputStream to be read")
198         }
199 
readnull200         override fun read(): Int {
201             throw NotImplementedError("read() should be unused")
202         }
203 
204         /**
205          * Attempt to read [len] bytes at offset [off].
206          *
207          * This will block until some data is available if no data currently is (so this method
208          * never returns 0 if [len] > 0).
209          */
readnull210         override fun read(b: ByteArray, off: Int, len: Int): Int {
211             if (position >= TEST_FILESIZE) return -1 // End of stream
212 
213             while (available <= position) {
214                 available = nextAvailableQueue.take()
215             }
216 
217             // Read the requested bytes (but not more than available).
218             val remaining = available - position
219             val readLen = min(len, remaining)
220             for (i in 0 until readLen) {
221                 b[off + i] = (position % 256).toByte()
222                 position++
223             }
224 
225             return readLen
226         }
227     }
228 
229     @Before
setUpnull230     fun setUp() {
231         TestNetwork.sTestConnection = connection
232         doReturn(200).`when`(connection).responseCode
233         doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong
234 
235         ActivityScenario.launch(RequestDismissKeyguardActivity::class.java)
236     }
237 
238     /**
239      * Create a temporary, empty file that can be used to read/write data for testing.
240      */
createTestFilenull241     private fun createTestFile(extension: String = ".png"): File {
242         // temp/ is as exported in file_paths.xml, so that the file can be shared externally
243         // (in the download success notification)
244         val testFilePath = File(context.getCacheDir(), "temp")
245         testFilePath.mkdir()
246         // Do not use File.createTempFile, as it generates very long filenames that may not
247         // fit in notifications, making it difficult to find the right notification.
248         // currentTimeMillis would generally be 13 digits. Use the bottom 8 to fit the filename and
249         // a bit more text, even on very small screens (320 dp, minimum CDD size).
250         var index = System.currentTimeMillis().rem(100_000_000)
251         while (true) {
252             val file = File(testFilePath, "tmp$index$extension")
253             if (!file.exists()) {
254                 file.createNewFile()
255                 return file
256             }
257             index++
258         }
259     }
260 
261     /**
262      * Make a file URI based on a file on disk, using a [FileProvider] that is registered for the
263      * test app.
264      */
makeFileUrinull265     private fun makeFileUri(testFile: File) = FileProvider.getUriForFile(
266             context,
267             // File provider registered in the test manifest
268             "com.android.captiveportallogin.tests.fileprovider",
269             testFile)
270 
271     @Test
272     fun testDownloadFile() {
273         val inputStream1 = TestInputStream()
274         doReturn(inputStream1).`when`(connection).inputStream
275 
276         val testFile1 = createTestFile()
277         val testFile2 = createTestFile()
278         assertNotEquals(testFile1.name, testFile2.name)
279         openNotificationShade()
280 
281         // Queue both downloads immediately: they should be started in order
282         val binder = bindService(makeDownloadCompleteCallback())
283         startDownloadTask(binder, testFile1, TEST_TEXT_FILE_TYPE)
284         startDownloadTask(binder, testFile2, TEST_TEXT_FILE_TYPE)
285 
286         verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream
287         val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name)
288 
289         findNotification(UiSelector().textContains(dlText1))
290 
291         // Allow download to progress to 1%
292         assertEquals(0, TEST_FILESIZE % 100)
293         assertTrue(TEST_FILESIZE / 100 > 0)
294         inputStream1.setAvailable(TEST_FILESIZE / 100)
295 
296         // Setup the connection for the next download with indeterminate progress
297         val inputStream2 = TestInputStream()
298         doReturn(inputStream2).`when`(connection).inputStream
299         doReturn(-1L).`when`(connection).contentLengthLong
300 
301         // Allow the first download to finish
302         inputStream1.setAvailable(TEST_FILESIZE)
303         verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect()
304 
305         FileInputStream(testFile1).use {
306             assertSameContents(it, TestInputStream(TEST_FILESIZE))
307         }
308 
309         testFile1.delete()
310 
311         // The second download should have started: make some data available
312         inputStream2.setAvailable(TEST_FILESIZE / 100)
313 
314         // A notification should be shown for the second download with indeterminate progress
315         val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name)
316         findNotification(UiSelector().textContains(dlText2))
317 
318         // Allow the second download to finish
319         inputStream2.setAvailable(TEST_FILESIZE)
320         verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect()
321 
322         FileInputStream(testFile2).use {
323             assertSameContents(it, TestInputStream(TEST_FILESIZE))
324         }
325 
326         testFile2.delete()
327     }
328 
makeDownloadCompleteCallbacknull329     fun makeDownloadCompleteCallback(
330         directlyOpenCompleteFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
331         downloadCompleteFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
332         downloadAbortedFuture: CompletableFuture<Boolean> = CompletableFuture<Boolean>(),
333         expectReason: Int = -1
334     ): ServiceConnection {
335         // Test callback to receive download completed callback.
336         return object : ServiceConnection {
337             override fun onServiceDisconnected(name: ComponentName) {}
338             override fun onServiceConnected(name: ComponentName, binder: IBinder) {
339                 val callback = object : ProgressCallback {
340                     override fun onDownloadComplete(
341                         inputFile: Uri,
342                         mimeType: String,
343                         downloadId: Int,
344                         success: Boolean
345                     ) {
346                         if (TEST_WIFI_CONFIG_TYPE.equals(mimeType)) {
347                             directlyOpenCompleteFuture.complete(success)
348                         } else {
349                             downloadCompleteFuture.complete(success)
350                         }
351                     }
352 
353                     override fun onDownloadAborted(downloadId: Int, reason: Int) {
354                         if (expectReason == reason) downloadAbortedFuture.complete(true)
355                     }
356                 }
357 
358                 (binder as DownloadServiceBinder).setProgressCallback(callback)
359             }
360         }
361     }
362 
363     @Test
testDirectlyOpenMimeType_fileSizeTooLargenull364     fun testDirectlyOpenMimeType_fileSizeTooLarge() {
365         val inputStream1 = TestInputStream()
366         doReturn(inputStream1).`when`(connection).inputStream
367         getInstrumentation().waitForIdleSync()
368         val outCfgFile = createTestDirectlyOpenFile()
369         val downloadAbortedFuture = CompletableFuture<Boolean>()
370         val mTestServiceConn = makeDownloadCompleteCallback(
371                 downloadAbortedFuture = downloadAbortedFuture,
372                 expectReason = DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE)
373 
374         try {
375             val binder = bindService(mTestServiceConn)
376             startDownloadTask(binder, outCfgFile, TEST_WIFI_CONFIG_TYPE)
377             inputStream1.setAvailable(TEST_FILESIZE)
378             // File size 1_000_000 is bigger than the limit(100_000). Download is expected to be
379             // aborted. Verify callback called when the download is complete.
380             assertTrue(downloadAbortedFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
381         } finally {
382             mServiceRule.unbindService()
383         }
384     }
385 
386     @Test
testDirectlyOpenMimeType_cancelTasknull387     fun testDirectlyOpenMimeType_cancelTask() {
388         val inputStream1 = TestInputStream()
389         doReturn(inputStream1).`when`(connection).inputStream
390 
391         val outCfgFile = createTestDirectlyOpenFile()
392         val outTextFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION")
393 
394         val directlyOpenCompleteFuture = CompletableFuture<Boolean>()
395         val otherCompleteFuture = CompletableFuture<Boolean>()
396         val testServiceConn = makeDownloadCompleteCallback(
397                 directlyOpenCompleteFuture = directlyOpenCompleteFuture,
398                 downloadCompleteFuture = otherCompleteFuture)
399 
400         try {
401             val binder = bindService(testServiceConn)
402             // Start directly open task first then follow with a generic one
403             val directlydlId = startDownloadTask(binder, outCfgFile, TEST_WIFI_CONFIG_TYPE)
404             startDownloadTask(binder, outTextFile, TEST_TEXT_FILE_TYPE)
405 
406             inputStream1.setAvailable(TEST_FILESIZE / 100)
407             // Cancel directly open task. The directly open task should result in a failed download
408             // complete. The cancel intent should not affect the other download task.
409             binder.cancelTask(directlydlId)
410             inputStream1.setAvailable(TEST_FILESIZE)
411             assertFalse(directlyOpenCompleteFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
412             assertTrue(otherCompleteFuture.get(TEST_TIMEOUT_MS, MILLISECONDS))
413         } finally {
414             mServiceRule.unbindService()
415         }
416     }
417 
createTestDirectlyOpenFilenull418     private fun createTestDirectlyOpenFile() = createTestFile(extension = ".wificonfig")
419 
420     private fun bindService(serviceConn: ServiceConnection): DownloadServiceBinder {
421         val binder = mServiceRule.bindService(Intent(context, DownloadService::class.java),
422                 serviceConn, Context.BIND_AUTO_CREATE) as DownloadServiceBinder
423         assertNotNull(binder)
424         return binder
425     }
426 
startDownloadTasknull427     private fun startDownloadTask(binder: DownloadServiceBinder, file: File, mimeType: String):
428             Int {
429         return binder.requestDownload(
430                 TestNetwork(),
431                 TEST_USERAGENT,
432                 TEST_URL,
433                 file.name,
434                 makeFileUri(file),
435                 context,
436                 mimeType)
437     }
438 
439     @Test
testTapDoneNotificationnull440     fun testTapDoneNotification() {
441         val fileContents = "Test file contents"
442         val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8))
443         doReturn(bis).`when`(connection).inputStream
444 
445         // The test extension is handled by OpenTextFileActivity in the test package
446         val testFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION")
447         openNotificationShade()
448 
449         val binder = bindService(makeDownloadCompleteCallback())
450         startDownloadTask(binder, testFile, TEST_TEXT_FILE_TYPE)
451 
452         // The download completed notification has the filename as contents, and
453         // R.string.download_completed as title. Find the contents using the filename as exact match
454         val note = findNotification(UiSelector().text(testFile.name))
455         note.click()
456 
457         // OpenTextFileActivity opens the file and shows contents
458         assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS))
459     }
460 
openNotificationShadenull461     private fun openNotificationShade() {
462         device.wakeUp()
463         device.openNotification()
464         assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS))
465     }
466 
findNotificationnull467     private fun findNotification(selector: UiSelector): UiObject {
468         val shadeScroller = UiScrollable(UiSelector().resourceId(NOTIFICATION_SHADE_TYPE))
469                 .setSwipeDeadZonePercentage(NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT)
470 
471         // Optimistically wait for the notification without scrolling (scrolling is slow)
472         val note = shadeScroller.getChild(selector)
473         if (note.waitForExists(NOTIFICATION_NO_SCROLL_TIMEOUT_MS)) return note
474 
475         val limit = System.currentTimeMillis() + TEST_TIMEOUT_MS
476         while (System.currentTimeMillis() < limit) {
477             // Similar to UiScrollable.scrollIntoView, but do not scroll up before going down (it
478             // could open the quick settings), and control the scroll steps (with a large swipe
479             // dead zone, scrollIntoView uses too many steps by default and is very slow).
480             for (i in 0 until NOTIFICATION_SCROLL_COUNT) {
481                 val canScrollFurther = shadeScroller.scrollForward(NOTIFICATION_SCROLL_STEPS)
482                 if (note.exists()) return note
483                 // Scrolled to the end, or scrolled too much and closed the shade
484                 if (!canScrollFurther || !shadeScroller.exists()) break
485             }
486 
487             // Go back to the top: close then reopen the notification shade.
488             // Do not scroll up, as it could open quick settings (and would be slower).
489             device.pressHome()
490             assertTrue(shadeScroller.waitUntilGone(TEST_TIMEOUT_MS))
491             openNotificationShade()
492 
493             Thread.sleep(NOTIFICATION_SCROLL_POLL_MS)
494         }
495         fail("Notification with selector $selector not found")
496     }
497 
498     /**
499      * Verify that two [InputStream] have the same content by reading them until the end of stream.
500      */
assertSameContentsnull501     private fun assertSameContents(s1: InputStream, s2: InputStream) {
502         val buffer1 = ByteArray(1000)
503         val buffer2 = ByteArray(1000)
504         while (true) {
505             // Read one chunk from s1
506             val read1 = s1.read(buffer1, 0, buffer1.size)
507             if (read1 < 0) break
508 
509             // Read a chunk of the same size from s2
510             var read2 = 0
511             while (read2 < read1) {
512                 s2.read(buffer2, read2, read1 - read2).also {
513                     assertFalse(it < 0, "Stream 2 is shorter than stream 1")
514                     read2 += it
515                 }
516             }
517             assertEquals(buffer1.take(read1), buffer2.take(read1))
518         }
519         assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1")
520     }
521 
522     /**
523      * Activity that reads a file specified as [Uri] in its start [Intent], and displays the file
524      * contents on screen by reading the file as UTF-8 text.
525      *
526      * The activity is registered in the manifest as a receiver for VIEW intents with a
527      * ".testtxtfile" URI.
528      */
529     class OpenTextFileActivity : Activity() {
onCreatenull530         override fun onCreate(savedInstanceState: Bundle?) {
531             super.onCreate(savedInstanceState)
532 
533             val testFile = intent.data ?: fail("This activity expects a file")
534             val fileStream = contentResolver.openInputStream(testFile)
535                     ?: fail("Could not open file InputStream")
536             val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use {
537                 it.readText()
538             }
539 
540             val view = TextView(this)
541             view.text = contents
542             setContentView(view)
543         }
544     }
545 }
546