OboeTester: guided disconnect test

Test whether Oboe disconnects a stream when a headset
is plugged in or unplugged.
Also listen for Intent.ACTION_HEADSET_PLUG.
diff --git a/apps/OboeTester/app/src/main/AndroidManifest.xml b/apps/OboeTester/app/src/main/AndroidManifest.xml
index 5911238..4591c57 100644
--- a/apps/OboeTester/app/src/main/AndroidManifest.xml
+++ b/apps/OboeTester/app/src/main/AndroidManifest.xml
@@ -86,6 +86,12 @@
             android:screenOrientation="portrait">
         </activity>
 
+        <activity
+            android:name="com.google.sample.oboe.manualtest.TestDisconnectActivity"
+            android:label="@string/title_test_disconnect"
+            android:screenOrientation="portrait">
+        </activity>
+
         <service
             android:name="com.google.sample.oboe.manualtest.AudioMidiTester"
             android:permission="android.permission.BIND_MIDI_DEVICE_SERVICE">
@@ -107,6 +113,7 @@
                 android:name="android.support.FILE_PROVIDER_PATHS"
                 android:resource="@xml/provider_paths"/>
         </provider>
+
     </application>
 
 </manifest>
\ No newline at end of file
diff --git a/apps/OboeTester/app/src/main/cpp/NativeAudioContext.cpp b/apps/OboeTester/app/src/main/cpp/NativeAudioContext.cpp
index ed0ee6c..cb1573a 100644
--- a/apps/OboeTester/app/src/main/cpp/NativeAudioContext.cpp
+++ b/apps/OboeTester/app/src/main/cpp/NativeAudioContext.cpp
@@ -605,3 +605,28 @@
         mFullDuplexGlitches->setOutputStream(oboeStream);
     }
 }
+
+
+// =================================================================== ActivityTestDisconnect
+void ActivityTestDisconnect::close(int32_t streamIndex) {
+    ActivityContext::close(streamIndex);
+    mSinkFloat.reset();
+}
+
+void ActivityTestDisconnect::configureForStart() {
+    mSinkFloat = std::make_unique<SinkFloat>(mChannelCount);
+    sineOscillator = std::make_unique<SineOscillator>();
+    monoToMulti = std::make_unique<MonoToMultiConverter>(mChannelCount);
+
+    oboe::AudioStream *outputStream = getOutputStream();
+    sineOscillator->setSampleRate(outputStream->getSampleRate());
+    sineOscillator->frequency.setValue(440.0);
+    sineOscillator->amplitude.setValue(AMPLITUDE_SINE);
+    sineOscillator->output.connect(&(monoToMulti->input));
+    monoToMulti->output.connect(&(mSinkFloat->input));
+    // Clear framePosition in sine oscillators.
+    mSinkFloat->pullReset();
+    audioStreamGateway.setAudioSink(mSinkFloat);
+    oboeCallbackProxy.setCallback(&audioStreamGateway);
+}
+
diff --git a/apps/OboeTester/app/src/main/cpp/NativeAudioContext.h b/apps/OboeTester/app/src/main/cpp/NativeAudioContext.h
index f48858b..9b81f89 100644
--- a/apps/OboeTester/app/src/main/cpp/NativeAudioContext.h
+++ b/apps/OboeTester/app/src/main/cpp/NativeAudioContext.h
@@ -599,6 +599,29 @@
 };
 
 /**
+ * Test a single output stream.
+ */
+class ActivityTestDisconnect : public ActivityContext {
+public:
+    ActivityTestDisconnect() {}
+
+    virtual ~ActivityTestDisconnect() = default;
+
+    void close(int32_t streamIndex) override;
+
+    oboe::Result startStreams() override {
+        return getOutputStream()->start();
+    }
+
+    void configureForStart() override;
+
+private:
+    std::unique_ptr<SineOscillator>         sineOscillator;
+    std::unique_ptr<MonoToMultiConverter>   monoToMulti;
+    std::shared_ptr<flowgraph::SinkFloat>   mSinkFloat;
+};
+
+/**
  * Switch between various
  */
 class NativeAudioContext {
@@ -635,6 +658,9 @@
             case ActivityType::Glitches:
                 currentActivity = &mActivityGlitches;
                 break;
+            case ActivityType::TestDisconnect:
+                currentActivity = &mActivityTestDisconnect;
+                break;
         }
     }
 
@@ -649,6 +675,7 @@
     ActivityEcho                 mActivityEcho;
     ActivityRoundTripLatency     mActivityRoundTripLatency;
     ActivityGlitches             mActivityGlitches;
+    ActivityTestDisconnect       mActivityTestDisconnect;
 
 private:
 
@@ -662,6 +689,7 @@
         Echo = 4,
         RoundTripLatency = 5,
         Glitches = 6,
+        TestDisconnect = 7,
     };
 
     ActivityType                 mActivityType = ActivityType::Undefined;
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
index 02ee804..6d379d5 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
@@ -189,6 +189,12 @@
         startActivity(intent);
     }
 
+    public void onLaunchTestDisconnect(View view) {
+        updateCallbackSize();
+        Intent intent = new Intent(this, TestDisconnectActivity.class);
+        startActivity(intent);
+    }
+
     public void onUseCallbackClicked(View view) {
         CheckBox checkBox = (CheckBox) view;
         OboeAudioStream.setUseCallback(checkBox.isChecked());
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
index c59b66a..cc4ed73 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
@@ -16,16 +16,10 @@
 
 package com.google.sample.oboe.manualtest;
 
-import android.content.Intent;
-import android.net.Uri;
 import android.os.Bundle;
-import android.os.Environment;
-import android.support.annotation.NonNull;
-import android.support.v4.content.FileProvider;
 import android.view.View;
 import android.widget.Button;
 
-import java.io.File;
 import java.io.IOException;
 
 /**
@@ -35,7 +29,7 @@
 
     private static final int STATE_RECORDING = 5;
     private static final int STATE_PLAYING = 6;
-    private int mRecorderState = STATE_STOPPED;
+    private int mRecorderState = AUDIO_STATE_STOPPED;
     private Button mRecordButton;
     private Button mStopButton;
     private Button mPlayButton;
@@ -55,7 +49,7 @@
         mStopButton = (Button) findViewById(R.id.button_stop_record_play);
         mPlayButton = (Button) findViewById(R.id.button_start_playback);
         mShareButton = (Button) findViewById(R.id.button_share);
-        mRecorderState = STATE_STOPPED;
+        mRecorderState = AUDIO_STATE_STOPPED;
         mGotRecording = false;
         updateButtons();
     }
@@ -81,7 +75,7 @@
     public void onStopRecordPlay(View view) {
         stopAudio();
         closeAudio();
-        mRecorderState = STATE_STOPPED;
+        mRecorderState = AUDIO_STATE_STOPPED;
         updateButtons();
     }
 
@@ -92,10 +86,10 @@
     }
 
     private void updateButtons() {
-        mRecordButton.setEnabled(mRecorderState == STATE_STOPPED);
-        mStopButton.setEnabled(mRecorderState != STATE_STOPPED);
-        mPlayButton.setEnabled(mRecorderState == STATE_STOPPED && mGotRecording);
-        mShareButton.setEnabled(mRecorderState == STATE_STOPPED && mGotRecording);
+        mRecordButton.setEnabled(mRecorderState == AUDIO_STATE_STOPPED);
+        mStopButton.setEnabled(mRecorderState != AUDIO_STATE_STOPPED);
+        mPlayButton.setEnabled(mRecorderState == AUDIO_STATE_STOPPED && mGotRecording);
+        mShareButton.setEnabled(mRecorderState == AUDIO_STATE_STOPPED && mGotRecording);
     }
 
     public void startPlayback() {
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfiguration.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfiguration.java
index 48ebd7b..7dc13fd 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfiguration.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfiguration.java
@@ -54,6 +54,8 @@
     public static final int RATE_CONVERSION_QUALITY_HIGH = 4; // must match Oboe
     public static final int RATE_CONVERSION_QUALITY_BEST = 5; // must match Oboe
 
+    public static final int STREAM_STATE_STARTING = 3; // must match Oboe
+    public static final int STREAM_STATE_STARTED = 4; // must match Oboe
 
     public static final int INPUT_PRESET_GENERIC = 1; // must match Oboe
     public static final int INPUT_PRESET_CAMCORDER = 5; // must match Oboe
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
index 13adb4a..87ea804 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
@@ -43,11 +43,13 @@
     public static final String TAG = "TestOboe";
 
     protected static final int FADER_PROGRESS_MAX = 1000;
-    public static final int STATE_OPEN = 0;
-    public static final int STATE_STARTED = 1;
-    public static final int STATE_PAUSED = 2;
-    public static final int STATE_STOPPED = 3;
-    public static final int STATE_CLOSED = 4;
+
+    public static final int AUDIO_STATE_OPEN = 0;
+    public static final int AUDIO_STATE_STARTED = 1;
+    public static final int AUDIO_STATE_PAUSED = 2;
+    public static final int AUDIO_STATE_STOPPED = 3;
+    public static final int AUDIO_STATE_CLOSED = 4;
+
     public static final int COLOR_ACTIVE = 0xFFD0D0A0;
     public static final int COLOR_IDLE = 0xFFD0D0D0;
 
@@ -60,8 +62,9 @@
     public static final int ACTIVITY_ECHO = 4;
     public static final int ACTIVITY_RT_LATENCY = 5;
     public static final int ACTIVITY_GLITCHES = 6;
+    public static final int ACTIVITY_TEST_DISCONNECT = 7;
 
-    private int mState = STATE_CLOSED;
+    private int mAudioState = AUDIO_STATE_CLOSED;
     protected String audioManagerSampleRate;
     protected int audioManagerFramesPerBurst;
     protected ArrayList<StreamContext> mStreamContexts;
@@ -178,23 +181,19 @@
 
     @Override
     protected void onDestroy() {
-        mState = STATE_CLOSED;
+        mAudioState = AUDIO_STATE_CLOSED;
         super.onDestroy();
     }
 
-    int getState() {
-        return mState;
-    }
-
     protected void updateEnabledWidgets() {
         if (mOpenButton != null) {
-            mOpenButton.setBackgroundColor(mState == STATE_OPEN ? COLOR_ACTIVE : COLOR_IDLE);
-            mStartButton.setBackgroundColor(mState == STATE_STARTED ? COLOR_ACTIVE : COLOR_IDLE);
-            mPauseButton.setBackgroundColor(mState == STATE_PAUSED ? COLOR_ACTIVE : COLOR_IDLE);
-            mStopButton.setBackgroundColor(mState == STATE_STOPPED ? COLOR_ACTIVE : COLOR_IDLE);
-            mCloseButton.setBackgroundColor(mState == STATE_CLOSED ? COLOR_ACTIVE : COLOR_IDLE);
+            mOpenButton.setBackgroundColor(mAudioState == AUDIO_STATE_OPEN ? COLOR_ACTIVE : COLOR_IDLE);
+            mStartButton.setBackgroundColor(mAudioState == AUDIO_STATE_STARTED ? COLOR_ACTIVE : COLOR_IDLE);
+            mPauseButton.setBackgroundColor(mAudioState == AUDIO_STATE_PAUSED ? COLOR_ACTIVE : COLOR_IDLE);
+            mStopButton.setBackgroundColor(mAudioState == AUDIO_STATE_STOPPED ? COLOR_ACTIVE : COLOR_IDLE);
+            mCloseButton.setBackgroundColor(mAudioState == AUDIO_STATE_CLOSED ? COLOR_ACTIVE : COLOR_IDLE);
         }
-        setConfigViewsEnabled(mState == STATE_CLOSED);
+        setConfigViewsEnabled(mAudioState == AUDIO_STATE_CLOSED);
     }
 
     private void setConfigViewsEnabled(boolean b) {
@@ -405,7 +404,7 @@
         requestedConfig.setFramesPerBurst(audioManagerFramesPerBurst);
         streamContext.tester.open();
         mSampleRate = actualConfig.getSampleRate();
-        mState = STATE_OPEN;
+        mAudioState = AUDIO_STATE_OPEN;
         int sessionId = actualConfig.getSessionId();
         if (sessionId > 0) {
             setupEffects(sessionId);
@@ -432,7 +431,7 @@
                     configView.updateDisplay();
                 }
             }
-            mState = STATE_STARTED;
+            mAudioState = AUDIO_STATE_STARTED;
             updateEnabledWidgets();
         }
     }
@@ -442,7 +441,7 @@
         if (result < 0) {
             showErrorToast("Pause failed with " + result);
         } else {
-            mState = STATE_PAUSED;
+            mAudioState = AUDIO_STATE_PAUSED;
             updateEnabledWidgets();
         }
     }
@@ -452,17 +451,23 @@
         if (result < 0) {
             showErrorToast("Stop failed with " + result);
         } else {
-            mState = STATE_STOPPED;
+            mAudioState = AUDIO_STATE_STOPPED;
             updateEnabledWidgets();
         }
     }
 
+    public void stopAudioQuiet() {
+        stopNative();
+        mAudioState = AUDIO_STATE_STOPPED;
+        updateEnabledWidgets();
+    }
+
     public void closeAudio() {
         mStreamSniffer.stopStreamSniffer();
         for (StreamContext streamContext : mStreamContexts) {
             streamContext.tester.close();
         }
-        mState = STATE_CLOSED;
+        mAudioState = AUDIO_STATE_CLOSED;
         updateEnabledWidgets();
     }
 
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestDisconnectActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestDisconnectActivity.java
new file mode 100644
index 0000000..3681468
--- /dev/null
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestDisconnectActivity.java
@@ -0,0 +1,445 @@
+/*
+ * Copyright 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.google.sample.oboe.manualtest;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.method.ScrollingMovementMethod;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import java.io.IOException;
+import java.util.Date;
+
+/**
+ * Guide the user through a series of tests plugging in and unplugging a headset.
+ * Print a summary at the end of any failures.
+ *
+ * TODO Test Input
+ */
+public class TestDisconnectActivity extends TestAudioActivity implements Runnable {
+
+    private static final String TEXT_SKIP = "SKIP";
+    private static final String TEXT_PASS = "PASS";
+    private static final String TEXT_FAIL = "FAIL !!!!";
+    public static final int POLL_DURATION_MILLIS = 50;
+    public static final int SETTLING_TIME_MILLIS = 600;
+    public static final int TIME_TO_FAILURE_MILLIS = 3000;
+
+    private TextView     mAutoTextView;
+    private TextView     mStatusTextView;
+    private TextView     mPlugTextView;
+    private AudioOutputTester    mAudioOutTester;
+
+    private Thread       mAutoThread;
+    private volatile boolean mThreadEnabled;
+    private volatile boolean mTestFailed;
+    private volatile boolean mSkipTest;
+    private volatile int mPlugCount;
+    private int          mTestCount;
+    private StringBuffer mFailedSummary;
+    private int          mPassCount;
+    private int          mFailCount;
+    private BroadcastReceiver pluginReceiver = new PluginBroadcastReceiver();
+    private Button       mStartButton;
+    private Button       mStopButton;
+    private Button       mShareButton;
+    private Button       mFailButton;
+    private Button       mSkipButton;
+
+    // Receive a broadcast Intent when a headset is plugged in or unplugged.
+    // Display a count on screen.
+    public class PluginBroadcastReceiver extends BroadcastReceiver {
+        private static final String TAG = "MyBroadcastReceiver";
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            mPlugCount++;
+            runOnUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    String message = "Intent.HEADSET_PLUG #" + mPlugCount;
+                    mPlugTextView.setText(message);
+                }
+            });
+        }
+    }
+
+    @Override
+    protected void inflateActivity() {
+        setContentView(R.layout.activity_test_disconnect);
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        mStatusTextView = (TextView) findViewById(R.id.text_analyzer_result);
+        mPlugTextView = (TextView) findViewById(R.id.text_plug_events);
+        mAutoTextView = (TextView) findViewById(R.id.text_auto_result);
+        mAutoTextView.setMovementMethod(new ScrollingMovementMethod());
+
+        mStartButton = (Button) findViewById(R.id.button_start);
+        mStopButton = (Button) findViewById(R.id.button_stop);
+        mShareButton = (Button) findViewById(R.id.button_share);
+        mShareButton.setEnabled(false);
+        mFailButton = (Button) findViewById(R.id.button_fail);
+        mSkipButton = (Button) findViewById(R.id.button_skip);
+        updateStartStopButtons(false);
+        updateFailSkipButton(false);
+
+        mAudioOutTester = addAudioOutputTester();
+    }
+
+    private void updateStartStopButtons(boolean running) {
+        mStartButton.setEnabled(!running);
+        mStopButton.setEnabled(running);
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        setActivityType(ACTIVITY_TEST_DISCONNECT);
+    }
+
+    @Override
+    boolean isOutput() {
+        return true;
+    }
+
+    @Override
+    public void setupEffects(int sessionId) {
+    }
+
+    private void updateFailSkipButton(final boolean running) {
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                mFailButton.setEnabled(running);
+                mSkipButton.setEnabled(running);
+            }
+        });
+    }
+
+    // Write to scrollable TextView
+    private void log(final String text) {
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                mAutoTextView.append(text);
+                mAutoTextView.append("\n");
+            }
+        });
+    }
+
+    // Write to status and command view
+    private void setStatusText(final String text) {
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                mStatusTextView.setText(text);
+            }
+        });
+    }
+
+    private void logClear() {
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                mAutoTextView.setText("");
+            }
+        });
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        IntentFilter filter = new IntentFilter(Intent.ACTION_HEADSET_PLUG);
+        this.registerReceiver(pluginReceiver, filter);
+    }
+
+    @Override
+    public void onPause() {
+        this.unregisterReceiver(pluginReceiver);
+        super.onPause();
+    }
+
+    // Only call from UI thread.
+    public void onTestFinished() {
+        updateStartStopButtons(false);
+        mShareButton.setEnabled(true);
+    }
+
+    public void startAudioTest() throws IOException {
+        openAudio();
+        startAudio();
+    }
+
+    public void stopAudioTest() {
+        stopAudioQuiet();
+        closeAudio();
+    }
+
+    public void onCancel(View view) {
+        stopAudioTest();
+        onTestFinished();
+    }
+
+    // Called on UI thread
+    public void onStopAudioTest(View view) {
+        stopAudioTest();
+        onTestFinished();
+        keepScreenOn(false);
+    }
+
+    public void onStartDisconnectTest(View view) {
+        updateStartStopButtons(true);
+        mThreadEnabled = true;
+        mAutoThread = new Thread(this);
+        mAutoThread.start();
+    }
+
+    public void onStopDisconnectTest(View view) {
+        try {
+            if (mAutoThread != null) {
+                mThreadEnabled = false;
+                mAutoThread.interrupt();
+                mAutoThread.join(100);
+                mAutoThread = null;
+            }
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+    }
+
+    public void onFailTest(View view) {
+        mTestFailed = true;
+    }
+
+    public void onSkipTest(View view) {
+        mSkipTest = true;
+    }
+
+    // Share text from log via GMail, Drive or other method.
+    public void onShareResult(View view) {
+        Intent sharingIntent = new Intent(Intent.ACTION_SEND);
+        sharingIntent.setType("text/plain");
+
+        String subjectText = "OboeTester Test Disconnect result " + getTimestampString();
+        sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subjectText);
+
+        String shareBody = mAutoTextView.getText().toString();
+        sharingIntent.putExtra(Intent.EXTRA_TEXT, shareBody);
+
+        startActivity(Intent.createChooser(sharingIntent, "Share using:"));
+    }
+
+    private String getConfigText(StreamConfiguration config) {
+        return ((config.getDirection() == StreamConfiguration.DIRECTION_OUTPUT) ? "OUT" : "IN")
+                + ", Perf = " + StreamConfiguration.convertPerformanceModeToText(
+                config.getPerformanceMode())
+                + ", " + StreamConfiguration.convertSharingModeToText(config.getSharingMode());
+    }
+
+    private void testConfiguration(int perfMode,
+                                   int sharingMode,
+                                   int channelCount,
+                                   boolean requestPlugin) throws InterruptedException {
+        String actualConfigText = "none";
+        mSkipTest = false;
+        // Configure settings
+        StreamConfiguration requestedConfig = mAudioOutTester.requestedConfiguration;
+        StreamConfiguration actualConfig = mAudioOutTester.actualConfiguration;
+
+        requestedConfig.reset();
+        requestedConfig.setPerformanceMode(perfMode);
+        requestedConfig.setSharingMode(sharingMode);
+        requestedConfig.setChannelCount(channelCount);
+
+        log("========================== #" + mTestCount);
+        log("Requested:");
+        log(getConfigText(requestedConfig));
+
+        // Give previous stream time to close and release resources. Avoid race conditions.
+        Thread.sleep(SETTLING_TIME_MILLIS);
+        if (!mThreadEnabled) return;
+        boolean openFailed = false;
+        try {
+            startAudioTest(); // this will fill in actualConfig
+            log("Actual:");
+            actualConfigText = getConfigText(actualConfig)
+                    + ", path = " + (actualConfig.isMMap() ? "MMAP" : "Legacy");
+            log(actualConfigText);
+            // Set output size to a level that will avoid glitches.
+            AudioStreamBase stream = mAudioOutTester.getCurrentAudioStream();
+            int sizeFrames = stream.getBufferCapacityInFrames() / 2;
+            stream.setBufferSizeInFrames(sizeFrames);
+        } catch (IOException e) {
+            openFailed = true;
+            log(e.getMessage());
+        }
+
+        // The test is only worth running if we got the configuration we requested.
+        boolean valid = true;
+        if (!openFailed) {
+            if(actualConfig.getSharingMode() != sharingMode) {
+                log("did not get requested sharing mode");
+                valid = false;
+            }
+            if (actualConfig.getPerformanceMode() != perfMode) {
+                log("did not get requested performance mode");
+                valid = false;
+            }
+            if (actualConfig.getNativeApi() == StreamConfiguration.NATIVE_API_OPENSLES) {
+                log("OpenSL ES does not support automatic disconnect");
+                valid = false;
+            }
+        }
+
+        int oldPlugCount = mPlugCount;
+        if (!openFailed && valid) {
+            mTestFailed = false;
+            updateFailSkipButton(true);
+            // poll for stream disconnected
+            while (!mTestFailed && mThreadEnabled && !mSkipTest &&
+                    mAudioOutTester.getCurrentAudioStream().getState() == StreamConfiguration.STREAM_STATE_STARTING) {
+                Thread.sleep(POLL_DURATION_MILLIS);
+            }
+            String message = (requestPlugin ? "Plug IN" : "UNplug") + " headset now!";
+            setStatusText(message);
+            int timeoutCount = 0;
+            while (!mTestFailed && mThreadEnabled && !mSkipTest &&
+                    mAudioOutTester.getCurrentAudioStream().getState() == StreamConfiguration.STREAM_STATE_STARTED) {
+                Thread.sleep(POLL_DURATION_MILLIS);
+                if (mPlugCount > oldPlugCount) {
+                    timeoutCount = TIME_TO_FAILURE_MILLIS / POLL_DURATION_MILLIS;
+                    break;
+                }
+            }
+            while (!mTestFailed && mThreadEnabled && !mSkipTest && (timeoutCount > 0) &&
+                    mAudioOutTester.getCurrentAudioStream().getState() == StreamConfiguration.STREAM_STATE_STARTED) {
+                Thread.sleep(POLL_DURATION_MILLIS);
+                timeoutCount--;
+                if (timeoutCount == 0) {
+                    mTestFailed = true;
+                } else {
+                    setStatusText("Plug detected by Java.\nCounting down to Oboe failure: " + timeoutCount);
+                }
+            }
+            setStatusText(mTestFailed ? "Failed" : "Passed - detected");
+        }
+        updateFailSkipButton(false);
+
+        if (!openFailed) {
+            stopAudioTest();
+        }
+
+        if (mSkipTest) valid = false;
+
+        if (valid) {
+            if (openFailed) {
+                mFailedSummary.append("------ #" + mTestCount);
+                mFailedSummary.append("\n");
+                mFailedSummary.append(getConfigText(requestedConfig));
+                mFailedSummary.append("\n");
+                mFailedSummary.append("Open failed!\n");
+                mFailCount++;
+            } else {
+                log("Result:");
+                boolean passed = !mTestFailed;
+                String resultText = requestPlugin ? "plugIN" : "UNplug";
+                resultText += ", " + (passed ? TEXT_PASS : TEXT_FAIL);
+                log(resultText);
+                if (!passed) {
+                    mFailedSummary.append("------ #" + mTestCount);
+                    mFailedSummary.append("\n");
+                    mFailedSummary.append("  ");
+                    mFailedSummary.append(actualConfigText);
+                    mFailedSummary.append("\n");
+                    mFailedSummary.append("    ");
+                    mFailedSummary.append(resultText);
+                    mFailedSummary.append("\n");
+                    mFailCount++;
+                } else {
+                    mPassCount++;
+                }
+            }
+        } else {
+            log(TEXT_SKIP);
+        }
+        // Give hardware time to settle between tests.
+        Thread.sleep(1000);
+        mTestCount++;
+    }
+
+    private void testConfiguration(int performanceMode,
+                                   int sharingMode) throws InterruptedException {
+        int channelCount = 2;
+        boolean requestPlugin = true; // plug IN
+        testConfiguration(performanceMode, sharingMode, channelCount, requestPlugin);
+        requestPlugin = false; // UNplug
+        testConfiguration(performanceMode, sharingMode, channelCount, requestPlugin);
+    }
+
+    @Override
+    public void run() {
+        mPlugCount = 0;
+        logClear();
+        log("=== STARTED at " + new Date());
+        log(Build.MANUFACTURER + " " + Build.PRODUCT);
+        log(Build.DISPLAY);
+        mFailedSummary = new StringBuffer();
+        mTestCount = 0;
+        mPassCount = 0;
+        mFailCount = 0;
+        // Try several different configurations.
+        try {
+            testConfiguration(StreamConfiguration.PERFORMANCE_MODE_LOW_LATENCY,
+                        StreamConfiguration.SHARING_MODE_EXCLUSIVE);
+            testConfiguration(StreamConfiguration.PERFORMANCE_MODE_LOW_LATENCY,
+                        StreamConfiguration.SHARING_MODE_SHARED);
+            testConfiguration(StreamConfiguration.PERFORMANCE_MODE_NONE,
+                        StreamConfiguration.SHARING_MODE_SHARED);
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        } finally {
+            stopAudioTest();
+            setStatusText("Finished. See summary below.");
+            log("\n==== SUMMARY ========");
+            if (mFailCount > 0) {
+                log(mPassCount + " passed. " + mFailCount + " failed.");
+                log("These tests FAILED:");
+                log(mFailedSummary.toString());
+            } else {
+                log("All tests PASSED.");
+            }
+            log("== FINISHED at " + new Date());
+            runOnUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    onTestFinished();
+                }
+            });
+            updateFailSkipButton(false);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/apps/OboeTester/app/src/main/res/layout/activity_main.xml b/apps/OboeTester/app/src/main/res/layout/activity_main.xml
index 39f07ff..bd8d407 100644
--- a/apps/OboeTester/app/src/main/res/layout/activity_main.xml
+++ b/apps/OboeTester/app/src/main/res/layout/activity_main.xml
@@ -73,6 +73,13 @@
         android:onClick="onLaunchAutoGlitchTest"
         android:text="Auto Glitch Test" />
 
+    <Button
+        android:id="@+id/button_test_disconnect"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:onClick="onLaunchTestDisconnect"
+        android:text="Test Disconnect" />
+
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
diff --git a/apps/OboeTester/app/src/main/res/layout/activity_test_disconnect.xml b/apps/OboeTester/app/src/main/res/layout/activity_test_disconnect.xml
new file mode 100644
index 0000000..ffcdf13
--- /dev/null
+++ b/apps/OboeTester/app/src/main/res/layout/activity_test_disconnect.xml
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:paddingBottom="@dimen/activity_vertical_margin"
+    android:paddingLeft="@dimen/activity_horizontal_margin"
+    android:paddingRight="@dimen/activity_horizontal_margin"
+    android:paddingTop="@dimen/activity_vertical_margin"
+    tools:context="com.google.sample.oboe.manualtest.TestDisconnectActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/button_start"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:onClick="onStartDisconnectTest"
+            android:text="@string/startAudio" />
+
+        <Button
+            android:id="@+id/button_stop"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:onClick="onStopDisconnectTest"
+            android:text="@string/stopAudio" />
+
+        <Button
+            android:id="@+id/button_share"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:onClick="onShareResult"
+            android:text="@string/share" />
+    </LinearLayout>
+
+
+    <TextView
+        android:id="@+id/text_analyzer_result"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:lines="4"
+        android:text="@string/test_disconnect_instructions"
+        android:textSize="18sp"
+        android:textStyle="bold"
+        />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/button_fail"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:onClick="onFailTest"
+            android:text="@string/failTest" />
+
+        <Button
+            android:id="@+id/button_skip"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:onClick="onSkipTest"
+            android:text="@string/skipTest" />
+
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/text_plug_events"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:lines="1"
+        android:text="plug #"
+        android:textSize="18sp"
+        android:textStyle="bold"
+        />
+
+    <TextView
+        android:id="@+id/text_auto_result"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:scrollbars = "vertical"
+        android:gravity="bottom"
+        android:text="@string/log_of_glitch_tests"
+        />
+
+</LinearLayout>
diff --git a/apps/OboeTester/app/src/main/res/values/strings.xml b/apps/OboeTester/app/src/main/res/values/strings.xml
index d9fac72..f6b61ec 100644
--- a/apps/OboeTester/app/src/main/res/values/strings.xml
+++ b/apps/OboeTester/app/src/main/res/values/strings.xml
@@ -131,6 +131,10 @@
 
     <string name="src_prompt">SRC:</string>
     <string name="average">Average</string>
+    <string name="failTest">Fail</string>
+    <string name="skipTest">Skip</string>
+    <string name="title_test_disconnect">Test Disconnect</string>
+    <string name="test_disconnect_instructions">Unplug all headsets then click [START]</string>
     <string-array name="conversion_qualities">
         <item>None</item>
         <item>Fastest</item>