| /* |
| * Copyright (C) 2019 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.os.bugreports.tests; |
| |
| import static com.google.common.truth.Truth.assertThat; |
| |
| import static org.junit.Assert.assertNotNull; |
| import static org.junit.Assert.assertTrue; |
| import static org.junit.Assert.fail; |
| |
| import android.Manifest; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.os.BugreportManager; |
| import android.os.BugreportManager.BugreportCallback; |
| import android.os.BugreportParams; |
| import android.os.FileUtils; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.os.ParcelFileDescriptor; |
| import android.os.Process; |
| import android.os.StrictMode; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.test.InstrumentationRegistry; |
| import androidx.test.filters.LargeTest; |
| import androidx.test.uiautomator.By; |
| import androidx.test.uiautomator.BySelector; |
| import androidx.test.uiautomator.UiDevice; |
| import androidx.test.uiautomator.UiObject2; |
| import androidx.test.uiautomator.Until; |
| |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Rule; |
| import org.junit.Test; |
| import org.junit.rules.ExternalResource; |
| import org.junit.rules.TestName; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.util.concurrent.CountDownLatch; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.TimeUnit; |
| |
| /** |
| * Tests for BugreportManager API. |
| */ |
| @RunWith(JUnit4.class) |
| public class BugreportManagerTest { |
| @Rule public TestName name = new TestName(); |
| @Rule public ExtendedStrictModeVmPolicy mTemporaryVmPolicy = new ExtendedStrictModeVmPolicy(); |
| |
| private static final String TAG = "BugreportManagerTest"; |
| private static final long BUGREPORT_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(10); |
| private static final long DUMPSTATE_STARTUP_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10); |
| private static final long UIAUTOMATOR_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10); |
| |
| |
| // A small timeout used when waiting for the result of a BugreportCallback to be received. |
| // This value must be at least 1000ms since there is an intentional delay in |
| // BugreportManagerServiceImpl in the error case. |
| private static final long CALLBACK_RESULT_TIMEOUT_MS = 1500; |
| |
| // Sent by Shell when its bugreport finishes (contains final bugreport/screenshot file name |
| // associated with the bugreport). |
| private static final String INTENT_BUGREPORT_FINISHED = |
| "com.android.internal.intent.action.BUGREPORT_FINISHED"; |
| private static final String EXTRA_BUGREPORT = "android.intent.extra.BUGREPORT"; |
| private static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT"; |
| |
| private Handler mHandler; |
| private Executor mExecutor; |
| private BugreportManager mBrm; |
| private File mBugreportFile; |
| private File mScreenshotFile; |
| private ParcelFileDescriptor mBugreportFd; |
| private ParcelFileDescriptor mScreenshotFd; |
| |
| @Before |
| public void setup() throws Exception { |
| mHandler = createHandler(); |
| mExecutor = (runnable) -> { |
| if (mHandler != null) { |
| mHandler.post(() -> { |
| runnable.run(); |
| }); |
| } |
| }; |
| |
| mBrm = getBugreportManager(); |
| mBugreportFile = createTempFile("bugreport_" + name.getMethodName(), ".zip"); |
| mScreenshotFile = createTempFile("screenshot_" + name.getMethodName(), ".png"); |
| mBugreportFd = parcelFd(mBugreportFile); |
| mScreenshotFd = parcelFd(mScreenshotFile); |
| |
| getPermissions(); |
| } |
| |
| @After |
| public void teardown() throws Exception { |
| dropPermissions(); |
| FileUtils.closeQuietly(mBugreportFd); |
| FileUtils.closeQuietly(mScreenshotFd); |
| } |
| |
| |
| @Test |
| public void normalFlow_wifi() throws Exception { |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| // wifi bugreport does not take screenshot |
| mBrm.startBugreport(mBugreportFd, null /*screenshotFd = null*/, wifi(), |
| mExecutor, callback); |
| shareConsentDialog(ConsentReply.ALLOW); |
| waitTillDoneOrTimeout(callback); |
| |
| assertThat(callback.isDone()).isTrue(); |
| // Wifi bugreports should not receive any progress. |
| assertThat(callback.hasReceivedProgress()).isFalse(); |
| assertThat(mBugreportFile.length()).isGreaterThan(0L); |
| assertThat(callback.hasEarlyReportFinished()).isTrue(); |
| assertFdsAreClosed(mBugreportFd); |
| } |
| |
| @LargeTest |
| @Test |
| public void normalFlow_interactive() throws Exception { |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| // interactive bugreport does not take screenshot |
| mBrm.startBugreport(mBugreportFd, null /*screenshotFd = null*/, interactive(), |
| mExecutor, callback); |
| shareConsentDialog(ConsentReply.ALLOW); |
| waitTillDoneOrTimeout(callback); |
| |
| assertThat(callback.isDone()).isTrue(); |
| // Interactive bugreports show progress updates. |
| assertThat(callback.hasReceivedProgress()).isTrue(); |
| assertThat(mBugreportFile.length()).isGreaterThan(0L); |
| assertThat(callback.hasEarlyReportFinished()).isTrue(); |
| assertFdsAreClosed(mBugreportFd); |
| } |
| |
| @LargeTest |
| @Test |
| public void normalFlow_full() throws Exception { |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| mBrm.startBugreport(mBugreportFd, mScreenshotFd, full(), mExecutor, callback); |
| shareConsentDialog(ConsentReply.ALLOW); |
| waitTillDoneOrTimeout(callback); |
| |
| assertThat(callback.isDone()).isTrue(); |
| // bugreport and screenshot files shouldn't be empty when user consents. |
| assertThat(mBugreportFile.length()).isGreaterThan(0L); |
| assertThat(mScreenshotFile.length()).isGreaterThan(0L); |
| assertFdsAreClosed(mBugreportFd, mScreenshotFd); |
| } |
| |
| @Test |
| public void simultaneousBugreportsNotAllowed() throws Exception { |
| // Start bugreport #1 |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| mBrm.startBugreport(mBugreportFd, mScreenshotFd, wifi(), mExecutor, callback); |
| // TODO(b/162389762) Make sure the wait time is reasonable |
| shareConsentDialog(ConsentReply.ALLOW); |
| |
| // Before #1 is done, try to start #2. |
| assertThat(callback.isDone()).isFalse(); |
| BugreportCallbackImpl callback2 = new BugreportCallbackImpl(); |
| File bugreportFile2 = createTempFile("bugreport_2_" + name.getMethodName(), ".zip"); |
| File screenshotFile2 = createTempFile("screenshot_2_" + name.getMethodName(), ".png"); |
| ParcelFileDescriptor bugreportFd2 = parcelFd(bugreportFile2); |
| ParcelFileDescriptor screenshotFd2 = parcelFd(screenshotFile2); |
| mBrm.startBugreport(bugreportFd2, screenshotFd2, wifi(), mExecutor, callback2); |
| Thread.sleep(CALLBACK_RESULT_TIMEOUT_MS); |
| |
| // Verify #2 encounters an error. |
| assertThat(callback2.getErrorCode()).isEqualTo( |
| BugreportCallback.BUGREPORT_ERROR_ANOTHER_REPORT_IN_PROGRESS); |
| assertFdsAreClosed(bugreportFd2, screenshotFd2); |
| |
| // Cancel #1 so we can move on to the next test. |
| mBrm.cancelBugreport(); |
| waitTillDoneOrTimeout(callback); |
| assertThat(callback.isDone()).isTrue(); |
| assertFdsAreClosed(mBugreportFd, mScreenshotFd); |
| } |
| |
| @Test |
| public void cancelBugreport() throws Exception { |
| // Start a bugreport. |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| mBrm.startBugreport(mBugreportFd, mScreenshotFd, wifi(), mExecutor, callback); |
| |
| // Verify it's not finished yet. |
| assertThat(callback.isDone()).isFalse(); |
| |
| // Try to cancel it, but first without DUMP permission. |
| dropPermissions(); |
| try { |
| mBrm.cancelBugreport(); |
| fail("Expected cancelBugreport to throw SecurityException without DUMP permission"); |
| } catch (SecurityException expected) { |
| } |
| assertThat(callback.isDone()).isFalse(); |
| |
| // Try again, with DUMP permission. |
| getPermissions(); |
| mBrm.cancelBugreport(); |
| waitTillDoneOrTimeout(callback); |
| assertThat(callback.isDone()).isTrue(); |
| assertFdsAreClosed(mBugreportFd, mScreenshotFd); |
| } |
| |
| @Test |
| public void cancelBugreport_noReportStarted() throws Exception { |
| // Without the native DumpstateService running, we don't get a SecurityException. |
| mBrm.cancelBugreport(); |
| } |
| |
| @LargeTest |
| @Test |
| public void cancelBugreport_fromDifferentUid() throws Exception { |
| assertThat(Process.myUid()).isNotEqualTo(Process.SHELL_UID); |
| |
| // Start a bugreport through ActivityManager's shell command - this starts a BR from the |
| // shell UID rather than our own. |
| BugreportBroadcastReceiver br = new BugreportBroadcastReceiver(); |
| InstrumentationRegistry.getContext() |
| .registerReceiver(br, new IntentFilter(INTENT_BUGREPORT_FINISHED)); |
| UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) |
| .executeShellCommand("am bug-report"); |
| |
| // The command triggers the report through a broadcast, so wait until dumpstate actually |
| // starts up, which may take a bit. |
| waitTillDumpstateRunningOrTimeout(); |
| |
| try { |
| mBrm.cancelBugreport(); |
| fail("Expected cancelBugreport to throw SecurityException when report started by " |
| + "different UID"); |
| } catch (SecurityException expected) { |
| } finally { |
| // Do this in the finally block so that even if this test case fails, we don't break |
| // other test cases unexpectedly due to the still-running shell report. |
| try { |
| // The shell's BR is still running and should complete successfully. |
| br.waitForBugreportFinished(); |
| } finally { |
| // The latch may fail for a number of reasons but we still need to unregister the |
| // BroadcastReceiver. |
| InstrumentationRegistry.getContext().unregisterReceiver(br); |
| } |
| } |
| } |
| |
| @Test |
| public void insufficientPermissions_throwsException() throws Exception { |
| dropPermissions(); |
| |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| try { |
| mBrm.startBugreport(mBugreportFd, mScreenshotFd, wifi(), mExecutor, callback); |
| fail("Expected startBugreport to throw SecurityException without DUMP permission"); |
| } catch (SecurityException expected) { |
| } |
| assertFdsAreClosed(mBugreportFd, mScreenshotFd); |
| } |
| |
| @Test |
| public void invalidBugreportMode_throwsException() throws Exception { |
| BugreportCallbackImpl callback = new BugreportCallbackImpl(); |
| |
| try { |
| mBrm.startBugreport(mBugreportFd, mScreenshotFd, |
| new BugreportParams(25) /* unknown bugreport mode */, mExecutor, callback); |
| fail("Expected to throw IllegalArgumentException with unknown bugreport mode"); |
| } catch (IllegalArgumentException expected) { |
| } |
| assertFdsAreClosed(mBugreportFd, mScreenshotFd); |
| } |
| |
| private Handler createHandler() { |
| HandlerThread handlerThread = new HandlerThread("BugreportManagerTest"); |
| handlerThread.start(); |
| return new Handler(handlerThread.getLooper()); |
| } |
| |
| /* Implementatiion of {@link BugreportCallback} that offers wrappers around execution result */ |
| private static final class BugreportCallbackImpl extends BugreportCallback { |
| private int mErrorCode = -1; |
| private boolean mSuccess = false; |
| private boolean mReceivedProgress = false; |
| private boolean mEarlyReportFinished = false; |
| private final Object mLock = new Object(); |
| |
| @Override |
| public void onProgress(float progress) { |
| synchronized (mLock) { |
| mReceivedProgress = true; |
| } |
| } |
| |
| @Override |
| public void onError(int errorCode) { |
| synchronized (mLock) { |
| mErrorCode = errorCode; |
| Log.d(TAG, "bugreport errored."); |
| } |
| } |
| |
| @Override |
| public void onFinished() { |
| synchronized (mLock) { |
| Log.d(TAG, "bugreport finished."); |
| mSuccess = true; |
| } |
| } |
| |
| @Override |
| public void onEarlyReportFinished() { |
| synchronized (mLock) { |
| mEarlyReportFinished = true; |
| } |
| } |
| |
| /* Indicates completion; and ended up with a success or error. */ |
| public boolean isDone() { |
| synchronized (mLock) { |
| return (mErrorCode != -1) || mSuccess; |
| } |
| } |
| |
| public int getErrorCode() { |
| synchronized (mLock) { |
| return mErrorCode; |
| } |
| } |
| |
| public boolean isSuccess() { |
| synchronized (mLock) { |
| return mSuccess; |
| } |
| } |
| |
| public boolean hasReceivedProgress() { |
| synchronized (mLock) { |
| return mReceivedProgress; |
| } |
| } |
| |
| public boolean hasEarlyReportFinished() { |
| synchronized (mLock) { |
| return mEarlyReportFinished; |
| } |
| } |
| } |
| |
| public static BugreportManager getBugreportManager() { |
| Context context = InstrumentationRegistry.getContext(); |
| BugreportManager bm = |
| (BugreportManager) context.getSystemService(Context.BUGREPORT_SERVICE); |
| if (bm == null) { |
| throw new AssertionError("Failed to get BugreportManager"); |
| } |
| return bm; |
| } |
| |
| private static File createTempFile(String prefix, String extension) throws Exception { |
| final File f = File.createTempFile(prefix, extension); |
| f.setReadable(true, true); |
| f.setWritable(true, true); |
| |
| f.deleteOnExit(); |
| return f; |
| } |
| |
| private static ParcelFileDescriptor parcelFd(File file) throws Exception { |
| return ParcelFileDescriptor.open(file, |
| ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_APPEND); |
| } |
| |
| private static void dropPermissions() { |
| InstrumentationRegistry.getInstrumentation().getUiAutomation() |
| .dropShellPermissionIdentity(); |
| } |
| |
| private static void getPermissions() { |
| InstrumentationRegistry.getInstrumentation().getUiAutomation() |
| .adoptShellPermissionIdentity(Manifest.permission.DUMP); |
| } |
| |
| private static boolean isDumpstateRunning() { |
| String[] output; |
| try { |
| output = |
| UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) |
| .executeShellCommand("ps -A -o NAME | grep dumpstate") |
| .trim() |
| .split("\n"); |
| } catch (IOException e) { |
| Log.w(TAG, "Failed to check if dumpstate is running", e); |
| return false; |
| } |
| for (String line : output) { |
| // Check for an exact match since there may be other things that contain "dumpstate" as |
| // a substring (e.g. the dumpstate HAL). |
| if (TextUtils.equals("dumpstate", line)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private static void assertFdIsClosed(ParcelFileDescriptor pfd) { |
| try { |
| int fd = pfd.getFd(); |
| fail("Expected ParcelFileDescriptor argument to be closed, but got: " + fd); |
| } catch (IllegalStateException expected) { |
| } |
| } |
| |
| private static void assertFdsAreClosed(ParcelFileDescriptor... pfds) { |
| for (int i = 0; i < pfds.length; i++) { |
| assertFdIsClosed(pfds[i]); |
| } |
| } |
| |
| private static long now() { |
| return System.currentTimeMillis(); |
| } |
| |
| private static void waitTillDumpstateRunningOrTimeout() throws Exception { |
| long startTimeMs = now(); |
| while (!isDumpstateRunning()) { |
| Thread.sleep(500 /* .5s */); |
| if (now() - startTimeMs >= DUMPSTATE_STARTUP_TIMEOUT_MS) { |
| break; |
| } |
| Log.d(TAG, "Waited " + (now() - startTimeMs) + "ms for dumpstate to start"); |
| } |
| } |
| |
| private static void waitTillDoneOrTimeout(BugreportCallbackImpl callback) throws Exception { |
| long startTimeMs = now(); |
| while (!callback.isDone()) { |
| Thread.sleep(1000 /* 1s */); |
| if (now() - startTimeMs >= BUGREPORT_TIMEOUT_MS) { |
| break; |
| } |
| Log.d(TAG, "Waited " + (now() - startTimeMs) + "ms for bugreport to finish"); |
| } |
| } |
| |
| /* |
| * Returns a {@link BugreportParams} for wifi only bugreport. |
| * |
| * <p>Wifi bugreports have minimal content and are fast to run. They also suppress progress |
| * updates. |
| */ |
| private static BugreportParams wifi() { |
| return new BugreportParams(BugreportParams.BUGREPORT_MODE_WIFI); |
| } |
| |
| /* |
| * Returns a {@link BugreportParams} for interactive bugreport that offers progress updates. |
| * |
| * <p>This is the typical bugreport taken by users. This can take on the order of minutes to |
| * finish. |
| */ |
| private static BugreportParams interactive() { |
| return new BugreportParams(BugreportParams.BUGREPORT_MODE_INTERACTIVE); |
| } |
| |
| /* |
| * Returns a {@link BugreportParams} for full bugreport that includes a screenshot. |
| * |
| * <p> This can take on the order of minutes to finish |
| */ |
| private static BugreportParams full() { |
| return new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL); |
| } |
| |
| /* Allow/deny the consent dialog to sharing bugreport data or check existence only. */ |
| private enum ConsentReply { |
| ALLOW, |
| DENY, |
| TIMEOUT |
| } |
| |
| /* |
| * Ensure the consent dialog is shown and take action according to <code>consentReply<code/>. |
| * It will fail if the dialog is not shown when <code>ignoreNotFound<code/> is false. |
| */ |
| private void shareConsentDialog(@NonNull ConsentReply consentReply) throws Exception { |
| mTemporaryVmPolicy.permitIncorrectContextUse(); |
| final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); |
| |
| // Unlock before finding/clicking an object. |
| device.wakeUp(); |
| device.executeShellCommand("wm dismiss-keyguard"); |
| |
| final BySelector consentTitleObj = By.res("android", "alertTitle"); |
| if (!device.wait(Until.hasObject(consentTitleObj), UIAUTOMATOR_TIMEOUT_MS)) { |
| fail("The consent dialog is not found"); |
| } |
| if (consentReply.equals(ConsentReply.TIMEOUT)) { |
| return; |
| } |
| final BySelector selector; |
| if (consentReply.equals(ConsentReply.ALLOW)) { |
| selector = By.res("android", "button1"); |
| Log.d(TAG, "Allow the consent dialog"); |
| } else { // ConsentReply.DENY |
| selector = By.res("android", "button2"); |
| Log.d(TAG, "Deny the consent dialog"); |
| } |
| final UiObject2 btnObj = device.findObject(selector); |
| assertNotNull("The button of consent dialog is not found", btnObj); |
| btnObj.click(); |
| |
| Log.d(TAG, "Wait for the dialog to be dismissed"); |
| assertTrue(device.wait(Until.gone(consentTitleObj), UIAUTOMATOR_TIMEOUT_MS)); |
| } |
| |
| private class BugreportBroadcastReceiver extends BroadcastReceiver { |
| Intent mBugreportFinishedIntent = null; |
| final CountDownLatch mLatch; |
| |
| BugreportBroadcastReceiver() { |
| mLatch = new CountDownLatch(1); |
| } |
| |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| setBugreportFinishedIntent(intent); |
| mLatch.countDown(); |
| } |
| |
| private void setBugreportFinishedIntent(Intent intent) { |
| mBugreportFinishedIntent = intent; |
| } |
| |
| public Intent getBugreportFinishedIntent() { |
| return mBugreportFinishedIntent; |
| } |
| |
| public void waitForBugreportFinished() throws Exception { |
| if (!mLatch.await(BUGREPORT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { |
| throw new Exception("Failed to receive BUGREPORT_FINISHED in " |
| + BUGREPORT_TIMEOUT_MS + " ms."); |
| } |
| } |
| } |
| |
| /** |
| * A rule to change strict mode vm policy temporarily till test method finished. |
| * |
| * To permit the non-visual context usage in tests while taking bugreports need user consent, |
| * or UiAutomator/BugreportManager.DumpstateListener would run into error. |
| * UiDevice#findObject creates UiObject2, its Gesture object and ViewConfiguration and |
| * UiObject2#click need to know bounds. Both of them access to WindowManager internally without |
| * visual context comes from InstrumentationRegistry and violate the policy. |
| * Also <code>DumpstateListener<code/> violate the policy when onScreenshotTaken is called. |
| * |
| * TODO(b/161201609) Remove this class once violations fixed. |
| */ |
| static class ExtendedStrictModeVmPolicy extends ExternalResource { |
| private boolean mWasVmPolicyChanged = false; |
| private StrictMode.VmPolicy mOldVmPolicy; |
| |
| @Override |
| protected void after() { |
| restoreVmPolicyIfNeeded(); |
| } |
| |
| public void permitIncorrectContextUse() { |
| // Allow to call multiple times without losing old policy. |
| if (mOldVmPolicy == null) { |
| mOldVmPolicy = StrictMode.getVmPolicy(); |
| } |
| StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() |
| .detectAll() |
| .permitIncorrectContextUse() |
| .penaltyLog() |
| .build()); |
| mWasVmPolicyChanged = true; |
| } |
| |
| private void restoreVmPolicyIfNeeded() { |
| if (mWasVmPolicyChanged && mOldVmPolicy != null) { |
| StrictMode.setVmPolicy(mOldVmPolicy); |
| mOldVmPolicy = null; |
| } |
| } |
| } |
| } |