diff --git a/packages/Shell/Android.mk b/packages/Shell/Android.mk index 5bd48c63433cd..f8c13d63327f3 100644 --- a/packages/Shell/Android.mk +++ b/packages/Shell/Android.mk @@ -3,7 +3,7 @@ include $(CLEAR_VARS) LOCAL_MODULE_TAGS := optional -LOCAL_SRC_FILES := $(call all-subdir-java-files) +LOCAL_SRC_FILES := $(call all-java-files-under, src) LOCAL_STATIC_JAVA_LIBRARIES := android-support-v4 @@ -12,3 +12,5 @@ LOCAL_CERTIFICATE := platform LOCAL_PRIVILEGED_MODULE := true include $(BUILD_PACKAGE) + +include $(LOCAL_PATH)/tests/Android.mk diff --git a/packages/Shell/AndroidManifest.xml b/packages/Shell/AndroidManifest.xml index 8c39ee654e6dc..4f087db136e99 100644 --- a/packages/Shell/AndroidManifest.xml +++ b/packages/Shell/AndroidManifest.xml @@ -1,7 +1,7 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/Shell/tests/src/com/android/shell/ActionSendMultipleConsumerActivity.java b/packages/Shell/tests/src/com/android/shell/ActionSendMultipleConsumerActivity.java new file mode 100644 index 0000000000000..e3e99b0d57114 --- /dev/null +++ b/packages/Shell/tests/src/com/android/shell/ActionSendMultipleConsumerActivity.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2015 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.shell; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.TimeUnit; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; + +/** + * Activity responsible for handling ACTION_SEND_MULTIPLE intents and passing them back to the test + * case class (through a {@link CustomActionSendMultipleListener}). + */ +public class ActionSendMultipleConsumerActivity extends Activity { + + private static final String CUSTOM_ACTION_SEND_MULTIPLE_INTENT = + "com.android.shell.tests.CUSTOM_ACTION_SEND_MULTIPLE"; + + private static CustomActionSendMultipleListener sListener; + + static final String UI_NAME = "ActionSendMultipleConsumer"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // The original intent cannot be broadcasted, it will fail due to security violations. + // Since the test case is only interested in the extras, we need to create a new custom + // intent with just them. + final Intent intent = getIntent(); + final Intent customIntent = new Intent(CUSTOM_ACTION_SEND_MULTIPLE_INTENT); + customIntent.putExtras(intent.getExtras()); + + getApplicationContext().sendBroadcast(customIntent); + } + + @Override + protected void onResume() { + super.onResume(); + /* + * TODO: if finish() is not called, app will crash with an exception such as: + * AndroidRuntime: java.lang.RuntimeException: Unable to resume activity + * {com.android.shell.tests/com.android.shell.SendMultipleActivity}: + * java.lang.IllegalStateException: Activity + * {com.android.shell.tests/com.android.shell.SendMultipleActivity} did not call finish() + * prior to onResume() completing. That seems to be a problem on M: + * https://code.google.com/p/android-developer-preview/issues/detail?id=2353 + */ + finish(); + } + + /** + * Gets the {@link CustomActionSendMultipleListener} singleton. + */ + static CustomActionSendMultipleListener getListener(Context context) { + synchronized (ActionSendMultipleConsumerActivity.class) { + if (sListener == null) { + sListener = new CustomActionSendMultipleListener(context); + } + } + return sListener; + } + + /** + * Listener of custom ACTION_SEND_MULTIPLE_INTENTS. + */ + static class CustomActionSendMultipleListener { + + private static final int TIMEOUT = 10; + private final BlockingQueue mQueue = new SynchronousQueue<>(); + + public CustomActionSendMultipleListener(Context context) { + BroadcastReceiver receiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + try { + mQueue.put(intent.getExtras()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + + final IntentFilter filter = new IntentFilter(); + filter.addAction(CUSTOM_ACTION_SEND_MULTIPLE_INTENT); + context.registerReceiver(receiver, filter); + } + + /** + * Gets the extras from the custom intent, blocking until it's received. + */ + Bundle getExtras() { + Bundle bundle = null; + try { + bundle = mQueue.poll(TIMEOUT, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (bundle == null) { + throw new IllegalStateException("Intent not received after " + TIMEOUT + "s"); + } + return bundle; + } + } +} diff --git a/packages/Shell/tests/src/com/android/shell/BugreportReceiverTest.java b/packages/Shell/tests/src/com/android/shell/BugreportReceiverTest.java new file mode 100644 index 0000000000000..06565c0b4ae69 --- /dev/null +++ b/packages/Shell/tests/src/com/android/shell/BugreportReceiverTest.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2015 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.shell; + +import static android.test.MoreAsserts.assertContainsRegex; +import static com.android.shell.ActionSendMultipleConsumerActivity.UI_NAME; +import static com.android.shell.BugreportReceiver.EXTRA_BUGREPORT; +import static com.android.shell.BugreportReceiver.EXTRA_SCREENSHOT; + +import java.io.BufferedOutputStream; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import libcore.io.Streams; +import android.app.Instrumentation; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemProperties; +import android.service.notification.StatusBarNotification; +import android.support.test.uiautomator.UiDevice; +import android.test.InstrumentationTestCase; +import android.util.Log; + +import com.android.shell.ActionSendMultipleConsumerActivity.CustomActionSendMultipleListener; + +/** + * Integration tests for {@link BugreportReceiver}. + *

+ * These tests don't mock any component and rely on external UI components (like the notification + * bar and activity chooser), which can make them unreliable and slow. + *

+ * The general workflow is: + *

+ *

+ * TODO: currently, these tests only work if the bug report sharing warning is disabled and the + * device screen is unlocked. + */ +public class BugreportReceiverTest extends InstrumentationTestCase { + + private static final String TAG = "BugreportReceiverTest"; + + // Timeout for UI operations, in milliseconds. + private static final int TIMEOUT = 1000; + + private static final String ROOT_DIR = "/data/data/com.android.shell/files/bugreports"; + private static final String BUGREPORT_FILE = "test_bugreport.txt"; + private static final String ZIP_FILE = "test_bugreport.zip"; + private static final String PLAIN_TEXT_PATH = ROOT_DIR + "/" + BUGREPORT_FILE; + private static final String ZIP_PATH = ROOT_DIR + "/" + ZIP_FILE; + private static final String SCREENSHOT_PATH = ROOT_DIR + "/test_screenshot.png"; + + private static final String BUGREPORT_CONTENT = "Dump, might as well dump!\n"; + private static final String SCREENSHOT_CONTENT = "A picture is worth a thousand words!\n"; + + private Context mContext; + private UiBot mUiBot; + private CustomActionSendMultipleListener mListener; + + @Override + protected void setUp() throws Exception { + Instrumentation instrumentation = getInstrumentation(); + mContext = instrumentation.getTargetContext(); + mUiBot = new UiBot(UiDevice.getInstance(instrumentation), TIMEOUT); + mListener = ActionSendMultipleConsumerActivity.getListener(mContext); + cancelExistingNotifications(); + } + + public void testBugreportFinished_plainBugreportAndScreenshot() throws Exception { + createTextFile(PLAIN_TEXT_PATH, BUGREPORT_CONTENT); + createTextFile(SCREENSHOT_PATH, SCREENSHOT_CONTENT); + Bundle extras = sendBugreportFinishedIntent(PLAIN_TEXT_PATH, SCREENSHOT_PATH); + assertActionSendMultiple(extras, BUGREPORT_CONTENT, SCREENSHOT_CONTENT); + } + + public void testBugreportFinished_zippedBugreportAndScreenshot() throws Exception { + createZipFile(ZIP_PATH, BUGREPORT_FILE, BUGREPORT_CONTENT); + createTextFile(SCREENSHOT_PATH, SCREENSHOT_CONTENT); + Bundle extras = sendBugreportFinishedIntent(ZIP_PATH, SCREENSHOT_PATH); + assertActionSendMultiple(extras, BUGREPORT_CONTENT, SCREENSHOT_CONTENT); + } + + public void testBugreportFinished_plainBugreportAndNoScreenshot() throws Exception { + createTextFile(PLAIN_TEXT_PATH, BUGREPORT_CONTENT); + Bundle extras = sendBugreportFinishedIntent(PLAIN_TEXT_PATH, null); + assertActionSendMultiple(extras, BUGREPORT_CONTENT, null); + } + + public void testBugreportFinished_zippedBugreportAndNoScreenshot() throws Exception { + createZipFile(ZIP_PATH, BUGREPORT_FILE, BUGREPORT_CONTENT); + Bundle extras = sendBugreportFinishedIntent(ZIP_PATH, null); + assertActionSendMultiple(extras, BUGREPORT_CONTENT, null); + } + + private void cancelExistingNotifications() { + NotificationManager nm = NotificationManager.from(mContext); + for (StatusBarNotification notification : nm.getActiveNotifications()) { + int id = notification.getId(); + Log.i(TAG, "Canceling existing notification (id=" + id + ")"); + nm.cancel(id); + } + } + + /** + * Sends a "bugreport finished" intent and waits for the result. + * + * @return extras sent to the bugreport finished consumer. + */ + private Bundle sendBugreportFinishedIntent(String bugreportPath, String screenshotPath) { + Intent intent = new Intent("android.intent.action.BUGREPORT_FINISHED"); + if (bugreportPath != null) { + intent.putExtra(EXTRA_BUGREPORT, bugreportPath); + } + if (screenshotPath != null) { + intent.putExtra(EXTRA_SCREENSHOT, screenshotPath); + } + + mContext.sendBroadcast(intent); + + mUiBot.clickOnNotification(mContext.getString(R.string.bugreport_finished_title)); + mUiBot.chooseActivity(UI_NAME); + return mListener.getExtras(); + } + + /** + * Asserts the proper ACTION_SEND_MULTIPLE intent was sent. + */ + private void assertActionSendMultiple(Bundle extras, String bugreportContent, + String screenshotContent) throws IOException { + String body = extras.getString(Intent.EXTRA_TEXT); + assertContainsRegex("missing build info", + SystemProperties.get("ro.build.description"), body); + assertContainsRegex("missing serial number", + SystemProperties.get("ro.serialno"), body); + + assertEquals("wrong subject", ZIP_FILE, extras.getString(Intent.EXTRA_SUBJECT)); + + List attachments = extras.getParcelableArrayList(Intent.EXTRA_STREAM); + int expectedSize = screenshotContent != null ? 2 : 1; + assertEquals("wrong number of attachments", expectedSize, attachments.size()); + + // Need to interact through all attachments, since order is not guaranteed. + Uri zipUri = null, screenshotUri = null; + for (Uri attachment : attachments) { + if (attachment.getPath().endsWith(".zip")) { + zipUri = attachment; + } + if (attachment.getPath().endsWith(".png")) { + screenshotUri = attachment; + } + } + assertNotNull("did not get .zip attachment", zipUri); + assertZipContent(zipUri, BUGREPORT_FILE, BUGREPORT_CONTENT); + + if (screenshotContent != null) { + assertNotNull("did not get .png attachment", screenshotUri); + assertContent(screenshotUri, SCREENSHOT_CONTENT); + } else { + assertNull("should not have .png attachment", screenshotUri); + } + } + + private void assertContent(Uri uri, String expectedContent) throws IOException { + Log.v(TAG, "assertContents(uri=" + uri); + try (InputStream is = mContext.getContentResolver().openInputStream(uri)) { + String actualContent = new String(Streams.readFully(is)); + assertEquals("wrong content for '" + uri + "'", expectedContent, actualContent); + } + } + + private void assertZipContent(Uri uri, String entryName, String expectedContent) + throws IOException, IOException { + Log.v(TAG, "assertZipEntry(uri=" + uri + ", entryName=" + entryName); + try (ZipInputStream zis = new ZipInputStream(mContext.getContentResolver().openInputStream( + uri))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Log.v(TAG, "Zip entry: " + entry.getName()); + if (entry.getName().equals(entryName)) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + Streams.copy(zis, bos); + String actualContent = new String(bos.toByteArray(), "UTF-8"); + bos.close(); + assertEquals("wrong content for zip entry'" + entryName + "' on '" + uri + "'", + expectedContent, actualContent); + return; + } + } + } + fail("Did not find entry '" + entryName + "' on file '" + uri + "'"); + } + + private static void createTextFile(String path, String content) throws IOException { + Log.v(TAG, "createFile(" + path + ")"); + try (Writer writer = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(path)))) { + writer.write(content); + } + } + + private void createZipFile(String path, String entryName, String content) throws IOException { + Log.v(TAG, "createZipFile(" + path + ", " + entryName + ")"); + try (ZipOutputStream zos = new ZipOutputStream( + new BufferedOutputStream(new FileOutputStream(path)))) { + ZipEntry entry = new ZipEntry(entryName); + zos.putNextEntry(entry); + byte[] data = content.getBytes(); + zos.write(data, 0, data.length); + zos.closeEntry(); + } + } +} diff --git a/packages/Shell/tests/src/com/android/shell/UiBot.java b/packages/Shell/tests/src/com/android/shell/UiBot.java new file mode 100644 index 0000000000000..f5dd31c6fdc02 --- /dev/null +++ b/packages/Shell/tests/src/com/android/shell/UiBot.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2015 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.shell; + +import android.support.test.uiautomator.By; +import android.support.test.uiautomator.UiDevice; +import android.support.test.uiautomator.UiObject; +import android.support.test.uiautomator.UiObjectNotFoundException; +import android.support.test.uiautomator.UiSelector; +import android.support.test.uiautomator.Until; +import android.util.Log; +import static junit.framework.Assert.assertTrue; + +/** + * A helper class for UI-related testing tasks. + */ +final class UiBot { + + private static final String TAG = "UiBot"; + private static final String SYSTEMUI_PACKAGED = "com.android.systemui"; + + private final UiDevice mDevice; + private final int mTimeout; + + public UiBot(UiDevice device, int timeout) { + mDevice = device; + mTimeout = timeout; + } + + /** + * Opens the system notification and clicks a given notification. + * + * @param text Notificaton's text as displayed by the UI. + */ + public void clickOnNotification(String text) { + boolean opened = mDevice.openNotification(); + Log.v(TAG, "openNotification(): " + opened); + boolean gotIt = mDevice.wait(Until.hasObject(By.pkg(SYSTEMUI_PACKAGED)), mTimeout); + assertTrue("could not get system ui (" + SYSTEMUI_PACKAGED + ")", gotIt); + + gotIt = mDevice.wait(Until.hasObject(By.text(text)), mTimeout); + assertTrue("object with text '(" + text + "') not visible yet", gotIt); + + UiObject notification = getVisibleObject(text); + + click(notification, "bug report notification"); + } + + /** + * Gets an object which is guaranteed to be present in the current UI.\ + * + * @param text Object's text as displayed by the UI. + */ + public UiObject getVisibleObject(String text) { + UiObject uiObject = mDevice.findObject(new UiSelector().text(text)); + assertTrue("could not find object with text '(" + text + "')", uiObject.exists()); + return uiObject; + } + + /** + * Clicks on a UI element. + * + * @param uiObject UI element to be clicked. + * @param description Elements's description used on logging statements. + */ + public void click(UiObject uiObject, String description) { + try { + boolean clicked = uiObject.click(); + // TODO: assertion below fails sometimes, even though the click succeeded, + // (specially when clicking the "Just Once" button), so it's currently just logged. + // assertTrue("could not click on object '" + description + "'", clicked); + + Log.v(TAG, "onClick for " + description + ": " + clicked); + } catch (UiObjectNotFoundException e) { + throw new IllegalStateException("exception when clicking on object '" + description + + "'", e); + } + } + + /** + * Chooses a given activity to handle an Intent, using the "Just Once" button. + * + * @param name name of the activity as displayed in the UI (typically the value set by + * {@code android:label} in the manifest). + */ + // TODO: UI Automator should provide such logic. + public void chooseActivity(String name) { + // First select activity if it's not the default option. + boolean gotIt = mDevice.wait(Until.hasObject(By.text(name)), mTimeout); + // TODO: if the activity is indeed the default option, call above will timeout, which will + // make the tests run slower. It might be better to change the logic to assume the default + // first. + if (gotIt) { + Log.v(TAG, "Found activity " + name + ", it's not default action"); + UiObject activityChooser = getVisibleObject(name); + click(activityChooser, "activity chooser"); + } else { + String text = String.format("Share with %s", name); + Log.v(TAG, "Didn't find activity " + name + + ", assuming it's the default action and search for '" + text + "'"); + gotIt = mDevice.wait(Until.hasObject(By.text(text)), mTimeout); + assertTrue("did not find text '" + text + "'", gotIt); + } + + // Then clicks the "Just Once" button. + gotIt = mDevice + .wait(Until.hasObject(By.res("android", "button_once")), mTimeout); + assertTrue("'Just Once' button not visible yet", gotIt); + + UiObject justOnce = mDevice + .findObject(new UiSelector().resourceId("android:id/button_once")); + assertTrue("'Just Once' button not found", justOnce.exists()); + + click(justOnce, "Just Once"); + } +}