Tests for the VR mode listener.

Bug=27977967
Change-Id: Id57e9112b01b1c66e0a9f48773fc234ead67ef46
diff --git a/apps/CtsVerifier/AndroidManifest.xml b/apps/CtsVerifier/AndroidManifest.xml
index b8e714f..b93ab4c 100644
--- a/apps/CtsVerifier/AndroidManifest.xml
+++ b/apps/CtsVerifier/AndroidManifest.xml
@@ -45,6 +45,8 @@
                   android:required="false" />
     <uses-feature android:name="android.hardware.camera.autofocus"
                   android:required="false" />
+    <uses-feature android:name="android.software.vr.mode"
+                  android:required="false" />
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -1206,14 +1208,53 @@
         </activity>
 
         <service android:name=".notifications.MockListener"
-                 android:exported="true"
-                 android:label="@string/nls_service_name"
-                 android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
+          android:exported="true"
+          android:label="@string/nls_service_name"
+          android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
             <intent-filter>
                 <action android:name="android.service.notification.NotificationListenerService" />
             </intent-filter>
         </service>
 
+        <activity android:name=".vr.VrListenerVerifierActivity"
+            android:label="@string/vr_tests">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.cts.intent.category.MANUAL_TEST" />
+            </intent-filter>
+            <meta-data android:name="test_category" android:value="@string/test_category_vr" />
+        </activity>
+
+        <activity android:name=".vr.MockVrActivity"
+            android:label="@string/vr_tests"
+            android:exported="false"
+            android:process=":TestVrActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <activity android:name=".vr.MockVrActivity2"
+            android:label="@string/vr_tests"
+            android:exported="false"
+            android:process=":TestVrActivity2">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <service android:name=".vr.MockVrListenerService"
+            android:exported="true"
+            android:enabled="true"
+            android:label="@string/vr_service_name"
+            android:permission="android.permission.BIND_VR_LISTENER_SERVICE">
+            <intent-filter>
+                <action android:name="android.service.vr.VrListenerService" />
+            </intent-filter>
+        </service>
+
         <service android:name=".notifications.MockConditionProvider"
                  android:exported="true"
                  android:label="@string/cp_service_name"
diff --git a/apps/CtsVerifier/res/layout/vr_item.xml b/apps/CtsVerifier/res/layout/vr_item.xml
new file mode 100644
index 0000000..b938747
--- /dev/null
+++ b/apps/CtsVerifier/res/layout/vr_item.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2016 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.
+-->
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content" >
+
+    <ImageView
+        android:id="@+id/vr_status"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentTop="true"
+        android:layout_marginTop="10dip"
+        android:contentDescription="@string/pass_button_text"
+        android:padding="10dip"
+        android:src="@drawable/fs_indeterminate" />
+
+    <TextView
+        android:id="@+id/vr_instructions"
+        style="@style/InstructionsSmallFont"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentRight="true"
+        android:layout_alignParentTop="true"
+        android:layout_toRightOf="@id/vr_status"
+        android:text="@string/vr_enable_service" />
+
+    <Button
+        android:id="@+id/vr_action_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentRight="true"
+        android:layout_below="@id/vr_instructions"
+        android:layout_marginLeft="20dip"
+        android:layout_marginRight="20dip"
+        android:layout_toRightOf="@id/vr_status"
+        android:onClick="actionPressed"
+        android:text="@string/vr_start_settings" />
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/apps/CtsVerifier/res/layout/vr_main.xml b/apps/CtsVerifier/res/layout/vr_main.xml
new file mode 100644
index 0000000..9f6b31d
--- /dev/null
+++ b/apps/CtsVerifier/res/layout/vr_main.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2016 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.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:padding="10dip"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ScrollView
+        android:id="@+id/vr_test_scroller"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:orientation="vertical"
+        android:padding="10dip" >
+
+        <LinearLayout
+            android:id="@+id/vr_test_items"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical" >
+        </LinearLayout>
+    </ScrollView>
+
+    <include
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_weight="0"
+        layout="@layout/pass_fail_buttons" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/apps/CtsVerifier/res/values/strings.xml b/apps/CtsVerifier/res/values/strings.xml
index 94e7bd4..a0834a5 100644
--- a/apps/CtsVerifier/res/values/strings.xml
+++ b/apps/CtsVerifier/res/values/strings.xml
@@ -1217,6 +1217,23 @@
         and disabled, and that once enabled the service is able to receive notifications and
         dismiss them.
     </string>
+    <string name="vr_tests">VR Tests</string>
+    <string name="test_category_vr">VR</string>
+    <string name="vr_test_title">VR Listener Test</string>
+    <string name="vr_service_name">VR Listener for CTS Verifier</string>
+    <string name="vr_info">This test checks that a VrListenerService can be enabled and disabled, and
+        and that it receives the correct lifecycle callbacks when entering and exiting VR mode.
+    </string>
+    <string name="vr_start_settings">Launch Settings</string>
+    <string name="vr_start_vr_activity">Launch VR mode activity</string>
+    <string name="vr_start_double_vr_activity">Launch Two VR mode activities</string>
+    <string name="vr_start_vr_activity_desc">Click the button to launch the VR mode activity.</string>
+    <string name="vr_start_vr_double_activity_desc">Click the button to launch two consecutive VR mode activities.</string>
+    <string name="vr_check_disabled">Check that the CTS VR helper service is disabled by default.</string>
+    <string name="vr_enable_service">Please enable \"VR Listener for CTS Verifier\"
+        under Apps > Gear Icon > Special Access > VR Helper Services and return here.</string>
+        <string name="vr_disable_service">Please disable \"VR Listener for CTS Verifier\"
+        under Apps > Gear Icon > Special Access > VR Helper Services and return here.</string>
     <string name="nls_enable_service">Please enable \"Notification Listener for CTS Verifier\"
         under Security > Notification Access and return here.</string>
     <string name="nls_disable_service">Please disable \"Notification Listener for CTS Verifier\"
diff --git a/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity.java b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity.java
new file mode 100644
index 0000000..bccd8ef
--- /dev/null
+++ b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2016 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.cts.verifier.vr;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+
+public class MockVrActivity extends Activity {
+    private static final String TAG = "MockVrActivity";
+    static final int EVENT_DELAY_MS = 1000;
+    private boolean mDoSecondIntent;
+    private Handler mHandler;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        Log.i(TAG, "onCreate called.");
+        super.onCreate(savedInstanceState);
+        try {
+            setVrModeEnabled(true, new ComponentName(this, MockVrListenerService.class));
+        } catch (PackageManager.NameNotFoundException e) {
+            Log.e(TAG, "Could not set VR mode: " + e);
+        }
+        mDoSecondIntent = getIntent().getBooleanExtra(
+                VrListenerVerifierActivity.EXTRA_LAUNCH_SECOND_INTENT, false);
+        mHandler = new Handler();
+    }
+
+    @Override
+    protected void onResume() {
+        Log.i(TAG, "onResume called.");
+
+        super.onResume();
+        if (mDoSecondIntent) {
+            mDoSecondIntent = false;
+            mHandler.postDelayed(new Runnable() {
+                @Override
+                public void run() {
+                    MockVrActivity.this.startActivity(new Intent(MockVrActivity.this,
+                            MockVrActivity2.class));
+                }
+            }, EVENT_DELAY_MS);
+        } else {
+            mHandler.postDelayed(new Runnable() {
+                @Override
+                public void run() {
+                    MockVrActivity.this.finish();
+                }
+            }, EVENT_DELAY_MS);
+        }
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus) {
+        Log.i(TAG, "onWindowFocusChanged called with " + hasFocus);
+        super.onWindowFocusChanged(hasFocus);
+    }
+
+    @Override
+    protected void onPause() {
+        Log.i(TAG, "onPause called.");
+        super.onPause();
+    }
+}
diff --git a/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity2.java b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity2.java
new file mode 100644
index 0000000..66b5630
--- /dev/null
+++ b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrActivity2.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2016 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.cts.verifier.vr;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+
+public class MockVrActivity2 extends Activity {
+    private static final String TAG = "MockVrActivity2";
+    private Handler mHandler;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        Log.i(TAG, "onCreate called.");
+        super.onCreate(savedInstanceState);
+        try {
+            setVrModeEnabled(true, new ComponentName(this, MockVrListenerService.class));
+        } catch (PackageManager.NameNotFoundException e) {
+            Log.e(TAG, "Could not set VR mode: " + e);
+        }
+        mHandler = new Handler();
+    }
+
+    @Override
+    protected void onResume() {
+        Log.i(TAG, "onResume called.");
+
+        super.onResume();
+        mHandler.postDelayed(new Runnable() {
+            @Override
+            public void run() {
+                MockVrActivity2.this.finish();
+            }
+        }, MockVrActivity.EVENT_DELAY_MS);
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus) {
+        Log.i(TAG, "onWindowFocusChanged called with " + hasFocus);
+        super.onWindowFocusChanged(hasFocus);
+    }
+
+    @Override
+    protected void onPause() {
+        Log.i(TAG, "onPause called.");
+        super.onPause();
+    }
+
+}
diff --git a/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrListenerService.java b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrListenerService.java
new file mode 100644
index 0000000..5c460a1
--- /dev/null
+++ b/apps/CtsVerifier/src/com/android/cts/verifier/vr/MockVrListenerService.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2016 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.cts.verifier.vr;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.IBinder;
+import android.service.vr.VrListenerService;
+import android.util.Log;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class MockVrListenerService extends VrListenerService {
+    private static final String TAG = "MockVrListener";
+    private static final AtomicInteger sNumBound = new AtomicInteger();
+
+    private static final ArrayBlockingQueue<Event> sEventQueue = new ArrayBlockingQueue<>(4096);
+
+    public static ArrayBlockingQueue<Event> getPendingEvents() {
+        return sEventQueue;
+    }
+
+    public static int getNumBoundMockVrListeners() {
+        return sNumBound.get();
+    }
+
+    public enum EventType{
+        ONBIND,
+        ONREBIND,
+        ONUNBIND,
+        ONCREATE,
+        ONDESTROY,
+        ONCURRENTVRMODEACTIVITYCHANGED
+    }
+
+    public static class Event {
+        public final VrListenerService instance;
+        public final EventType type;
+        public final Object arg1;
+
+        private Event(VrListenerService i, EventType t, Object o) {
+            instance = i;
+            type = t;
+            arg1 = o;
+        }
+
+        public static Event build(VrListenerService instance, EventType type, Object argument1) {
+            return new Event(instance, type, argument1);
+        }
+
+        public static Event build(VrListenerService instance, EventType type) {
+            return new Event(instance, type, null);
+        }
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        Log.i(TAG, "onBind called");
+        sNumBound.getAndIncrement();
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONBIND, intent));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+        return super.onBind(intent);
+    }
+
+    @Override
+    public void onRebind(Intent intent) {
+        Log.i(TAG, "onRebind called");
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONREBIND, intent));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+        super.onRebind(intent);
+    }
+
+    @Override
+    public boolean onUnbind(Intent intent) {
+        Log.i(TAG, "onUnbind called");
+        sNumBound.getAndDecrement();
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONUNBIND, intent));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+        return super.onUnbind(intent);
+    }
+
+    @Override
+    public void onCreate() {
+        Log.i(TAG, "onCreate called");
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONCREATE));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+        super.onCreate();
+    }
+
+    @Override
+    public void onDestroy() {
+        Log.i(TAG, "onDestroy called");
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONDESTROY));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+        super.onDestroy();
+    }
+
+    @Override
+    public void onCurrentVrActivityChanged(ComponentName component) {
+        Log.i(TAG, "onCurrentVrActivityChanged called with: " + component);
+        try {
+            sEventQueue.put(Event.build(this, EventType.ONCURRENTVRMODEACTIVITYCHANGED, component));
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Service thread interrupted: " + e);
+        }
+    }
+
+}
diff --git a/apps/CtsVerifier/src/com/android/cts/verifier/vr/VrListenerVerifierActivity.java b/apps/CtsVerifier/src/com/android/cts/verifier/vr/VrListenerVerifierActivity.java
new file mode 100644
index 0000000..c31adb4
--- /dev/null
+++ b/apps/CtsVerifier/src/com/android/cts/verifier/vr/VrListenerVerifierActivity.java
@@ -0,0 +1,626 @@
+/*
+ * Copyright (C) 2016 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.cts.verifier.vr;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+
+import com.android.cts.verifier.PassFailButtons;
+import com.android.cts.verifier.R;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public class VrListenerVerifierActivity extends PassFailButtons.Activity {
+
+    private static final String TAG = "VrListenerActivity";
+    public static final String ENABLED_VR_LISTENERS = "enabled_vr_listeners";
+    private static final String STATE = "state";
+    private static final int POLL_DELAY_MS = 2000;
+    static final String EXTRA_LAUNCH_SECOND_INTENT = "do2intents";
+
+    private LayoutInflater mInflater;
+    private InteractiveTestCase[] mTests;
+    private ViewGroup mTestViews;
+    private int mCurrentIdx;
+    private Handler mMainHandler;
+    private Handler mTestHandler;
+    private HandlerThread mTestThread;
+
+    public enum Status {
+        SETUP,
+        RUNNING,
+        PASS,
+        FAIL,
+        WAIT_FOR_USER;
+    }
+
+    @Override
+    protected void onCreate(Bundle savedState) {
+        super.onCreate(savedState);
+        mCurrentIdx = (savedState == null) ? 0 : savedState.getInt(STATE, 0);
+
+        mTestThread = new HandlerThread("VrTestThread");
+        mTestThread.start();
+        mTestHandler = new Handler(mTestThread.getLooper());
+        mInflater = getLayoutInflater();
+        View v = mInflater.inflate(R.layout.vr_main, null);
+        setContentView(v);
+        setPassFailButtonClickListeners();
+        getPassButton().setEnabled(false);
+        setInfoResources(R.string.vr_test_title, R.string.vr_info, -1);
+
+        mTestViews = (ViewGroup) v.findViewById(R.id.vr_test_items);
+        mTests = new InteractiveTestCase[] {
+                new IsDefaultDisabledTest(),
+                new UserEnableTest(),
+                new VrModeSwitchTest(),
+                new VrModeMultiSwitchTest(),
+                new UserDisableTest(),
+        };
+
+        for (InteractiveTestCase test : mTests) {
+            test.setStatus((savedState == null) ? Status.SETUP :
+                    Status.values()[savedState.getInt(test.getClass().getSimpleName(), 0)]);
+            mTestViews.addView(test.getView(mTestViews));
+        }
+
+        updateUiState();
+
+        mMainHandler = new Handler();
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        if (mTestThread != null) {
+            mTestThread.quit();
+        }
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        outState.putInt(STATE, mCurrentIdx);
+        for (InteractiveTestCase i : mTests) {
+            outState.putInt(i.getClass().getSimpleName(), i.getStatus().ordinal());
+        }
+        super.onSaveInstanceState(outState);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        runNext();
+    }
+
+    private void updateUiState() {
+        boolean allPassed = true;
+        for (InteractiveTestCase t : mTests) {
+            t.updateViews();
+            if (t.getStatus() != Status.PASS) {
+                allPassed = false;
+            }
+        }
+
+        if (allPassed) {
+            getPassButton().setEnabled(true);
+        }
+    }
+
+    protected void logWithStack(String message) {
+        logWithStack(message, null);
+    }
+
+    protected void logWithStack(String message, Throwable stackTrace) {
+        if (stackTrace == null) {
+            stackTrace = new Throwable();
+            stackTrace.fillInStackTrace();
+        }
+        Log.e(TAG, message, stackTrace);
+    }
+
+    private void selectNext() {
+        mCurrentIdx++;
+        if (mCurrentIdx >= mTests.length) {
+            done();
+            return;
+        }
+        final InteractiveTestCase current = mTests[mCurrentIdx];
+        current.markWaiting();
+    }
+
+    private void runNext() {
+        if (mCurrentIdx >= mTests.length) {
+            done();
+            return;
+        }
+        final InteractiveTestCase current = mTests[mCurrentIdx];
+        mTestHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                Log.i(TAG, "Starting test: " + current.getClass().getSimpleName());
+                boolean passed = true;
+                try {
+                    current.setUp();
+                    current.test();
+                } catch (Throwable e) {
+                    logWithStack("Failed " + current.getClass().getSimpleName() + " with: ", e);
+                    setFailed(current);
+                    passed = false;
+                } finally {
+                    try {
+                        current.tearDown();
+                    } catch (Throwable e) {
+                        logWithStack("Failed tearDown of " + current.getClass().getSimpleName() +
+                                " with: ", e);
+                        setFailed(current);
+                        passed = false;
+                    }
+                }
+                if (passed) {
+                    current.markPassed();
+                    mMainHandler.post(new Runnable() {
+                        @Override
+                        public void run() {
+                            selectNext();
+                        }
+                    });
+                }
+                Log.i(TAG, "Done test: " + current.getClass().getSimpleName());
+            }
+        });
+    }
+
+    private void done() {
+        updateUiState();
+        Log.i(TAG, "Completed run!");
+    }
+
+
+    private void setFailed(final InteractiveTestCase current) {
+        mMainHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                getPassButton().setEnabled(false);
+                current.markFailed();
+            }
+        });
+    }
+
+    protected View createUserInteractionTestView(ViewGroup parent, int stringId, int messageId) {
+        View v = mInflater.inflate(R.layout.vr_item, parent, false);
+        TextView instructions = (TextView) v.findViewById(R.id.vr_instructions);
+        instructions.setText(getString(messageId));
+        Button b = (Button) v.findViewById(R.id.vr_action_button);
+        b.setText(stringId);
+        b.setTag(stringId);
+        return v;
+    }
+
+    protected View createAutoTestView(ViewGroup parent, int messageId) {
+        View v = mInflater.inflate(R.layout.vr_item, parent, false);
+        TextView instructions = (TextView) v.findViewById(R.id.vr_instructions);
+        instructions.setText(getString(messageId));
+        Button b = (Button) v.findViewById(R.id.vr_action_button);
+        b.setVisibility(View.GONE);
+        return v;
+    }
+
+    protected abstract class InteractiveTestCase {
+        protected static final String TAG = "InteractiveTest";
+        private Status status;
+        private View view;
+
+        abstract View inflate(ViewGroup parent);
+
+        View getView(ViewGroup parent) {
+            if (view == null) {
+                view = inflate(parent);
+            }
+            return view;
+        }
+
+        abstract void test() throws Throwable;
+
+        void setUp() throws Throwable {
+            // Noop
+        }
+
+        void tearDown() throws Throwable {
+            // Noop
+        }
+
+        Status getStatus() {
+            return status;
+        }
+
+        void setStatus(Status s) {
+            status = s;
+        }
+
+        void markFailed() {
+            Log.i(TAG, "FAILED test: " + this.getClass().getSimpleName());
+            mMainHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    InteractiveTestCase.this.setStatus(Status.FAIL);
+                    updateViews();
+                }
+            });
+        }
+
+        void markPassed() {
+            Log.i(TAG, "PASSED test: " + this.getClass().getSimpleName());
+            mMainHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    InteractiveTestCase.this.setStatus(Status.PASS);
+                    updateViews();
+                }
+            });
+        }
+
+        void markFocused() {
+            mMainHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    InteractiveTestCase.this.setStatus(Status.SETUP);
+                    updateViews();
+                }
+            });
+        }
+
+        void markWaiting() {
+            mMainHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    InteractiveTestCase.this.setStatus(Status.WAIT_FOR_USER);
+                    updateViews();
+                }
+            });
+        }
+
+        void markRunning() {
+            mMainHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    InteractiveTestCase.this.setStatus(Status.RUNNING);
+                    updateViews();
+                }
+            });
+        }
+
+        private void updateViews() {
+            View item = view;
+            ImageView statusView = (ImageView) item.findViewById(R.id.vr_status);
+            View button = item.findViewById(R.id.vr_action_button);
+            switch (status) {
+                case WAIT_FOR_USER:
+                    statusView.setImageResource(R.drawable.fs_warning);
+                    button.setEnabled(true);
+                    break;
+                case SETUP:
+                    statusView.setImageResource(R.drawable.fs_indeterminate);
+                    button.setEnabled(false);
+                    break;
+                case RUNNING:
+                    statusView.setImageResource(R.drawable.fs_clock);
+                    break;
+                case FAIL:
+                    statusView.setImageResource(R.drawable.fs_error);
+                    break;
+                case PASS:
+                    statusView.setImageResource(R.drawable.fs_good);
+                    button.setClickable(false);
+                    button.setEnabled(false);
+                    break;
+            }
+            statusView.invalidate();
+        }
+    }
+
+    private static void assertTrue(String message, boolean b) {
+        if (!b) {
+            throw new IllegalStateException(message);
+        }
+    }
+
+    private static <E> void assertIn(String message, E elem, E[] c) {
+        if (!Arrays.asList(c).contains(elem)) {
+            throw new IllegalStateException(message);
+        }
+    }
+
+    private static void assertEventIn(String message, MockVrListenerService.Event elem,
+                                      MockVrListenerService.EventType[] c) {
+        if (!Arrays.asList(c).contains(elem.type)) {
+            throw new IllegalStateException(message);
+        }
+    }
+
+    protected void launchVrListenerSettings() {
+        VrListenerVerifierActivity.this.startActivity(
+                new Intent(Settings.ACTION_VR_LISTENER_SETTINGS));
+    }
+
+    protected void launchVrActivity() {
+        VrListenerVerifierActivity.this.startActivity(
+                new Intent(VrListenerVerifierActivity.this, MockVrActivity.class));
+    }
+
+    protected void launchDoubleVrActivity() {
+        VrListenerVerifierActivity.this.startActivity(
+                new Intent(VrListenerVerifierActivity.this, MockVrActivity.class).
+                        putExtra(EXTRA_LAUNCH_SECOND_INTENT, true));
+    }
+
+    public void actionPressed(View v) {
+        Object tag = v.getTag();
+        if (tag instanceof Integer) {
+            int id = ((Integer) tag).intValue();
+            if (id == R.string.vr_start_settings) {
+                launchVrListenerSettings();
+            } else if (id == R.string.vr_start_vr_activity) {
+                launchVrActivity();
+            } else if (id == R.string.vr_start_double_vr_activity) {
+                launchDoubleVrActivity();
+            }
+        }
+    }
+
+    private class IsDefaultDisabledTest extends InteractiveTestCase {
+
+        @Override
+        View inflate(ViewGroup parent) {
+            return createAutoTestView(parent, R.string.vr_check_disabled);
+        }
+
+        @Override
+        void setUp() {
+            markFocused();
+        }
+
+        @Override
+        void test() {
+            assertTrue("VR listeners should not be bound by default.",
+                    MockVrListenerService.getNumBoundMockVrListeners() == 0);
+        }
+    }
+
+    private class UserEnableTest extends InteractiveTestCase {
+
+        @Override
+        View inflate(ViewGroup parent) {
+            return createUserInteractionTestView(parent, R.string.vr_start_settings,
+                    R.string.vr_enable_service);
+        }
+
+        @Override
+        void setUp() {
+            markWaiting();
+        }
+
+        @Override
+        void test() {
+            String helpers = Settings.Secure.getString(getContentResolver(), ENABLED_VR_LISTENERS);
+            ComponentName c = new ComponentName(VrListenerVerifierActivity.this,
+                    MockVrListenerService.class);
+            if (MockVrListenerService.getPendingEvents().size() > 0) {
+                MockVrListenerService.getPendingEvents().clear();
+                throw new IllegalStateException("VrListenerService bound before entering VR mode!");
+            }
+            assertTrue("Settings must now contain " + c.flattenToString(),
+                    helpers != null && helpers.contains(c.flattenToString()));
+        }
+    }
+
+    private class VrModeSwitchTest extends InteractiveTestCase {
+
+        @Override
+        View inflate(ViewGroup parent) {
+            return createUserInteractionTestView(parent, R.string.vr_start_vr_activity,
+                    R.string.vr_start_vr_activity_desc);
+        }
+
+        @Override
+        void setUp() {
+            markWaiting();
+        }
+
+        @Override
+        void test() throws Throwable {
+            ArrayBlockingQueue<MockVrListenerService.Event> q =
+                    MockVrListenerService.getPendingEvents();
+            MockVrListenerService.Event e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive onCreate or onBind event from VrListenerService.",
+                    e != null);
+            assertEventIn("First listener service event must be onCreate or onBind, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONCREATE,
+                    MockVrListenerService.EventType.ONBIND
+            });
+            if (e.type == MockVrListenerService.EventType.ONCREATE) {
+                e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+                assertTrue("Timed out before receive onBind event from VrListenerService.",
+                        e != null);
+                assertEventIn("Second listener service event must be onBind, but was " +
+                        e.type, e, new MockVrListenerService.EventType[]{
+                        MockVrListenerService.EventType.ONBIND
+                });
+            }
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive onCurrentVrModeActivityChanged event " +
+                    "from VrListenerService.", e != null);
+            assertTrue("Listener service must receive onCurrentVrModeActivityChanged, but was " +
+                    e.type,
+                    e.type == MockVrListenerService.EventType.ONCURRENTVRMODEACTIVITYCHANGED);
+            ComponentName expected = new ComponentName(VrListenerVerifierActivity.this,
+                    MockVrActivity.class);
+            assertTrue("Activity component must be " + expected + ", but was: " + e.arg1,
+                    Objects.equals(expected, e.arg1));
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive unbind event from VrListenerService.", e != null);
+            assertEventIn("Listener service must receive onUnbind, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONUNBIND
+            });
+
+            // Consume onDestroy
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive onDestroy event from VrListenerService.",
+                    e != null);
+            assertEventIn("Listener service must receive onDestroy, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONDESTROY
+            });
+
+            markRunning();
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            if (e != null) {
+                throw new IllegalStateException("Spurious event received after onDestroy: "
+                        + e.type);
+            }
+        }
+    }
+
+    private class VrModeMultiSwitchTest extends InteractiveTestCase {
+
+        @Override
+        View inflate(ViewGroup parent) {
+            return createUserInteractionTestView(parent, R.string.vr_start_double_vr_activity,
+                    R.string.vr_start_vr_double_activity_desc);
+        }
+
+        @Override
+        void setUp() {
+            markWaiting();
+        }
+
+        @Override
+        void test() throws Throwable {
+            ArrayBlockingQueue<MockVrListenerService.Event> q =
+                    MockVrListenerService.getPendingEvents();
+            MockVrListenerService.Event e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive event from VrListenerService.", e != null);
+            assertEventIn("First listener service event must be onCreate or onBind, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONCREATE,
+                    MockVrListenerService.EventType.ONBIND
+            });
+            if (e.type == MockVrListenerService.EventType.ONCREATE) {
+                e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+                assertTrue("Timed out before receive event from VrListenerService.", e != null);
+                assertEventIn("Second listener service event must be onBind, but was " +
+                        e.type, e, new MockVrListenerService.EventType[]{
+                        MockVrListenerService.EventType.ONBIND
+                });
+            }
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive event from VrListenerService.", e != null);
+            assertTrue("Listener service must receive onCurrentVrModeActivityChanged, but received "
+                    + e.type, e.type ==
+                    MockVrListenerService.EventType.ONCURRENTVRMODEACTIVITYCHANGED);
+            ComponentName expected = new ComponentName(VrListenerVerifierActivity.this,
+                    MockVrActivity.class);
+            assertTrue("Activity component must be " + expected + ", but was: " + e.arg1,
+                    Objects.equals(expected, e.arg1));
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive event from VrListenerService.", e != null);
+            assertTrue("Listener service must receive onCurrentVrModeActivityChanged, but received "
+                    + e.type, e.type ==
+                    MockVrListenerService.EventType.ONCURRENTVRMODEACTIVITYCHANGED);
+            ComponentName expected2 = new ComponentName(VrListenerVerifierActivity.this,
+                    MockVrActivity2.class);
+            assertTrue("Activity component must be " + expected2 + ", but was: " + e.arg1,
+                    Objects.equals(expected2, e.arg1));
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive event from VrListenerService.", e != null);
+            assertTrue("Listener service must receive onCurrentVrModeActivityChanged, but received "
+                    + e.type, e.type ==
+                    MockVrListenerService.EventType.ONCURRENTVRMODEACTIVITYCHANGED);
+            assertTrue("Activity component must be " + expected + ", but was: " + e.arg1,
+                    Objects.equals(expected, e.arg1));
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive event from VrListenerService.", e != null);
+            assertEventIn("Listener service must receive onUnbind, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONUNBIND
+            });
+
+            // Consume onDestroy
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            assertTrue("Timed out before receive onDestroy event from VrListenerService.",
+                    e != null);
+            assertEventIn("Listener service must receive onDestroy, but was " +
+                    e.type, e, new MockVrListenerService.EventType[]{
+                    MockVrListenerService.EventType.ONDESTROY
+            });
+
+            markRunning();
+
+            e = q.poll(POLL_DELAY_MS, TimeUnit.MILLISECONDS);
+            if (e != null) {
+                throw new IllegalStateException("Spurious event received after onDestroy: "
+                        + e.type);
+            }
+        }
+    }
+
+    private class UserDisableTest extends InteractiveTestCase {
+
+        @Override
+        View inflate(ViewGroup parent) {
+            return createUserInteractionTestView(parent, R.string.vr_start_settings,
+                    R.string.vr_disable_service);
+        }
+
+        @Override
+        void setUp() {
+            markWaiting();
+        }
+
+        @Override
+        void test() {
+            String helpers = Settings.Secure.getString(getContentResolver(), ENABLED_VR_LISTENERS);
+            ComponentName c = new ComponentName(VrListenerVerifierActivity.this,
+                    MockVrListenerService.class);
+            assertTrue("Settings must no longer contain " + c.flattenToString(),
+                    helpers == null || !(helpers.contains(c.flattenToString())));
+        }
+    }
+
+}