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