blob: aba864ae7ff139e59b530a4e4393c406556ac288 [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.captiveportallogin
import android.app.Activity
import android.content.Intent
import android.net.Network
import android.net.Uri
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.webkit.MimeTypeMap
import android.widget.TextView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiScrollable
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.timeout
import org.mockito.Mockito.verify
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.nio.charset.StandardCharsets
import java.util.concurrent.SynchronousQueue
import java.util.concurrent.TimeUnit.MILLISECONDS
import kotlin.math.min
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import kotlin.test.fail
private val TEST_FILESIZE = 1_000_000 // 1MB
private val TEST_USERAGENT = "Test UserAgent"
private val TEST_URL = "https://test.download.example.com/myfile"
private val NOTIFICATION_SHADE_TYPE = "com.android.systemui:id/notification_stack_scroller"
// Test text file registered in the test manifest to be opened by a test activity
private val TEST_TEXT_FILE_EXTENSION = "testtxtfile"
private val TEST_TEXT_FILE_TYPE = "text/vnd.captiveportallogin.testtxtfile"
private val TEST_TIMEOUT_MS = 10_000L
// Timeout for notifications before trying to find it via scrolling
private val NOTIFICATION_NO_SCROLL_TIMEOUT_MS = 1000L
// Maximum number of scrolls from the top to attempt to find notifications in the notification shade
private val NOTIFICATION_SCROLL_COUNT = 30
// Swipe in a vertically centered area of 20% of the screen height (40% margin
// top/down): small swipes on notifications avoid dismissing the notification shade
private val NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT = .4
// Steps for each scroll in the notification shade (controls the scrolling speed).
// Each scroll is a series of cursor moves between multiple points on a line. The delay between each
// point is hard-coded, so the number of points (steps) controls how long the scroll takes.
private val NOTIFICATION_SCROLL_STEPS = 5
private val NOTIFICATION_SCROLL_POLL_MS = 100L
@RunWith(AndroidJUnit4::class)
@SmallTest
class DownloadServiceTest {
companion object {
@BeforeClass @JvmStatic
fun setUpClass() {
// Turn the MimeTypeMap for the process into a spy so test mimetypes can be added
val mimetypeMap = MimeTypeMap.getSingleton()
spyOn(mimetypeMap)
// Use a custom mimetype for the test to avoid cases where the device already has
// an app installed that can handle the detected mimetype (would be
// application/octet-stream by default for unusual extensions), which would cause the
// device to show a dialog to choose the app to use, and make it difficult to test.
doReturn(true).`when`(mimetypeMap).hasExtension(TEST_TEXT_FILE_EXTENSION)
doReturn(TEST_TEXT_FILE_TYPE).`when`(mimetypeMap).getMimeTypeFromExtension(
TEST_TEXT_FILE_EXTENSION)
}
}
private val connection = mock(HttpURLConnection::class.java)
private val context by lazy { getInstrumentation().context }
private val resources by lazy { context.resources }
private val device by lazy { UiDevice.getInstance(getInstrumentation()) }
// Test network that can be parceled in intents while mocking the connection
class TestNetwork(private val privateDnsBypass: Boolean = false)
: Network(43, privateDnsBypass) {
companion object {
// Subclasses of parcelable classes need to define a CREATOR field of their own (which
// hides the one of the parent class), otherwise the CREATOR field of the parent class
// would be used when unparceling and createFromParcel would return an instance of the
// parent class.
@JvmField
val CREATOR = object : Parcelable.Creator<TestNetwork> {
override fun createFromParcel(source: Parcel?) = TestNetwork()
override fun newArray(size: Int) = emptyArray<TestNetwork>()
}
/**
* Test [URLConnection] to be returned by all [TestNetwork] instances when
* [openConnection] is called.
*
* This can be set to a mock connection, and is a static to allow [TestNetwork]s to be
* parceled and unparceled without losing their mock configuration.
*/
internal var sTestConnection: HttpURLConnection? = null
}
override fun getPrivateDnsBypassingCopy(): Network {
// Note that the privateDnsBypass flag is not kept when parceling/unparceling: this
// mirrors the real behavior of that flag in Network.
// The test relies on this to verify that after setting privateDnsBypass to true,
// the TestNetwork is not parceled / unparceled, which would clear the flag both
// for TestNetwork or for a real Network and be a bug.
return TestNetwork(privateDnsBypass = true)
}
override fun openConnection(url: URL?): URLConnection {
// Verify that this network was created with privateDnsBypass = true, and was not
// parceled / unparceled afterwards (which would have cleared the flag).
assertTrue(privateDnsBypass,
"Captive portal downloads should be done on a network bypassing private DNS")
return sTestConnection ?: throw IllegalStateException(
"Mock URLConnection not initialized")
}
}
/**
* A test InputStream returning generated data.
*
* Reading this stream is not thread-safe: it should only be read by one thread at a time.
*/
private class TestInputStream(private var available: Int = 0) : InputStream() {
// position / available are only accessed in the reader thread
private var position = 0
private val nextAvailableQueue = SynchronousQueue<Int>()
/**
* Set how many bytes are available now without blocking.
*
* This is to be set on a thread controlling the amount of data that is available, while
* a reader thread may be trying to read the data.
*
* The reader thread will block until this value is increased, and if the reader is not yet
* waiting for the data to be made available, this method will block until it is.
*/
fun setAvailable(newAvailable: Int) {
assertTrue(nextAvailableQueue.offer(newAvailable.coerceIn(0, TEST_FILESIZE),
TEST_TIMEOUT_MS, MILLISECONDS),
"Timed out waiting for TestInputStream to be read")
}
override fun read(): Int {
throw NotImplementedError("read() should be unused")
}
/**
* Attempt to read [len] bytes at offset [off].
*
* This will block until some data is available if no data currently is (so this method
* never returns 0 if [len] > 0).
*/
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (position >= TEST_FILESIZE) return -1 // End of stream
while (available <= position) {
available = nextAvailableQueue.take()
}
// Read the requested bytes (but not more than available).
val remaining = available - position
val readLen = min(len, remaining)
for (i in 0 until readLen) {
b[off + i] = (position % 256).toByte()
position++
}
return readLen
}
}
@Before
fun setUp() {
TestNetwork.sTestConnection = connection
doReturn(200).`when`(connection).responseCode
doReturn(TEST_FILESIZE.toLong()).`when`(connection).contentLengthLong
ActivityScenario.launch(RequestDismissKeyguardActivity::class.java)
}
/**
* Create a temporary, empty file that can be used to read/write data for testing.
*/
private fun createTestFile(extension: String = ".png"): File {
// temp/ is as exported in file_paths.xml, so that the file can be shared externally
// (in the download success notification)
val testFilePath = File(context.getCacheDir(), "temp")
testFilePath.mkdir()
// Do not use File.createTempFile, as it generates very long filenames that may not
// fit in notifications, making it difficult to find the right notification.
// currentTimeMillis would generally be 13 digits. Use the bottom 8 to fit the filename and
// a bit more text, even on very small screens (320 dp, minimum CDD size).
var index = System.currentTimeMillis().rem(100_000_000)
while (true) {
val file = File(testFilePath, "tmp$index$extension")
if (!file.exists()) {
file.createNewFile()
return file
}
index++
}
}
private fun makeDownloadIntent(testFile: File) = DownloadService.makeDownloadIntent(
context,
TestNetwork(),
TEST_USERAGENT,
TEST_URL,
testFile.name,
makeFileUri(testFile))
/**
* Make a file URI based on a file on disk, using a [FileProvider] that is registered for the
* test app.
*/
private fun makeFileUri(testFile: File) = FileProvider.getUriForFile(
context,
// File provider registered in the test manifest
"com.android.captiveportallogin.tests.fileprovider",
testFile)
@Test
fun testDownloadFile() {
val inputStream1 = TestInputStream()
doReturn(inputStream1).`when`(connection).inputStream
val testFile1 = createTestFile()
val testFile2 = createTestFile()
assertNotEquals(testFile1.name, testFile2.name)
val downloadIntent1 = makeDownloadIntent(testFile1)
val downloadIntent2 = makeDownloadIntent(testFile2)
openNotificationShade()
// Queue both downloads immediately: they should be started in order
context.startForegroundService(downloadIntent1)
context.startForegroundService(downloadIntent2)
verify(connection, timeout(TEST_TIMEOUT_MS)).inputStream
val dlText1 = resources.getString(R.string.downloading_paramfile, testFile1.name)
findNotification(UiSelector().textContains(dlText1))
// Allow download to progress to 1%
assertEquals(0, TEST_FILESIZE % 100)
assertTrue(TEST_FILESIZE / 100 > 0)
inputStream1.setAvailable(TEST_FILESIZE / 100)
// Setup the connection for the next download with indeterminate progress
val inputStream2 = TestInputStream()
doReturn(inputStream2).`when`(connection).inputStream
doReturn(-1L).`when`(connection).contentLengthLong
// Allow the first download to finish
inputStream1.setAvailable(TEST_FILESIZE)
verify(connection, timeout(TEST_TIMEOUT_MS)).disconnect()
FileInputStream(testFile1).use {
assertSameContents(it, TestInputStream(TEST_FILESIZE))
}
testFile1.delete()
// The second download should have started: make some data available
inputStream2.setAvailable(TEST_FILESIZE / 100)
// A notification should be shown for the second download with indeterminate progress
val dlText2 = resources.getString(R.string.downloading_paramfile, testFile2.name)
findNotification(UiSelector().textContains(dlText2))
// Allow the second download to finish
inputStream2.setAvailable(TEST_FILESIZE)
verify(connection, timeout(TEST_TIMEOUT_MS).times(2)).disconnect()
FileInputStream(testFile2).use {
assertSameContents(it, TestInputStream(TEST_FILESIZE))
}
testFile2.delete()
}
@Test
fun testTapDoneNotification() {
val fileContents = "Test file contents"
val bis = ByteArrayInputStream(fileContents.toByteArray(StandardCharsets.UTF_8))
doReturn(bis).`when`(connection).inputStream
// The test extension is handled by OpenTextFileActivity in the test package
val testFile = createTestFile(extension = ".$TEST_TEXT_FILE_EXTENSION")
val downloadIntent = makeDownloadIntent(testFile)
openNotificationShade()
context.startForegroundService(downloadIntent)
// The download completed notification has the filename as contents, and
// R.string.download_completed as title. Find the contents using the filename as exact match
val note = findNotification(UiSelector().text(testFile.name))
note.click()
// OpenTextFileActivity opens the file and shows contents
assertTrue(device.wait(Until.hasObject(By.text(fileContents)), TEST_TIMEOUT_MS))
}
private fun openNotificationShade() {
device.wakeUp()
device.openNotification()
assertTrue(device.wait(Until.hasObject(By.res(NOTIFICATION_SHADE_TYPE)), TEST_TIMEOUT_MS))
}
private fun findNotification(selector: UiSelector): UiObject {
val shadeScroller = UiScrollable(UiSelector().resourceId(NOTIFICATION_SHADE_TYPE))
.setSwipeDeadZonePercentage(NOTIFICATION_SCROLL_DEAD_ZONE_PERCENT)
// Optimistically wait for the notification without scrolling (scrolling is slow)
val note = shadeScroller.getChild(selector)
if (note.waitForExists(NOTIFICATION_NO_SCROLL_TIMEOUT_MS)) return note
val limit = System.currentTimeMillis() + TEST_TIMEOUT_MS
while (System.currentTimeMillis() < limit) {
// Similar to UiScrollable.scrollIntoView, but do not scroll up before going down (it
// could open the quick settings), and control the scroll steps (with a large swipe
// dead zone, scrollIntoView uses too many steps by default and is very slow).
for (i in 0 until NOTIFICATION_SCROLL_COUNT) {
val canScrollFurther = shadeScroller.scrollForward(NOTIFICATION_SCROLL_STEPS)
if (note.exists()) return note
// Scrolled to the end, or scrolled too much and closed the shade
if (!canScrollFurther || !shadeScroller.exists()) break
}
// Go back to the top: close then reopen the notification shade.
// Do not scroll up, as it could open quick settings (and would be slower).
device.pressHome()
assertTrue(shadeScroller.waitUntilGone(TEST_TIMEOUT_MS))
openNotificationShade()
Thread.sleep(NOTIFICATION_SCROLL_POLL_MS)
}
fail("Notification with selector $selector not found")
}
/**
* Verify that two [InputStream] have the same content by reading them until the end of stream.
*/
private fun assertSameContents(s1: InputStream, s2: InputStream) {
val buffer1 = ByteArray(1000)
val buffer2 = ByteArray(1000)
while (true) {
// Read one chunk from s1
val read1 = s1.read(buffer1, 0, buffer1.size)
if (read1 < 0) break
// Read a chunk of the same size from s2
var read2 = 0
while (read2 < read1) {
s2.read(buffer2, read2, read1 - read2).also {
assertFalse(it < 0, "Stream 2 is shorter than stream 1")
read2 += it
}
}
assertEquals(buffer1.take(read1), buffer2.take(read1))
}
assertEquals(-1, s2.read(buffer2, 0, 1), "Stream 2 is longer than stream 1")
}
/**
* Activity that reads a file specified as [Uri] in its start [Intent], and displays the file
* contents on screen by reading the file as UTF-8 text.
*
* The activity is registered in the manifest as a receiver for VIEW intents with a
* ".testtxtfile" URI.
*/
class OpenTextFileActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val testFile = intent.data ?: fail("This activity expects a file")
val fileStream = contentResolver.openInputStream(testFile)
?: fail("Could not open file InputStream")
val contents = InputStreamReader(fileStream, StandardCharsets.UTF_8).use {
it.readText()
}
val view = TextView(this)
view.text = contents
setContentView(view)
}
}
}