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