[go: nahoru, domu]

Add all Camera code from internal repo

  This is a direct copy of our code from our internal repo. Still needs
  to be cleaned up for code style and to match AndroidX build files.

Bug: 121003129
Test: All unit tests pass before copy.
Change-Id: Id18d2e13c53fc35a013c7709598ee59b53472576
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/AndroidManifest.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/AndroidManifest.xml
new file mode 100644
index 0000000..69bde8e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="androidx.camera.app.camera2interopburst">
+
+    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27" />
+
+    <uses-permission android:name="android.permission.CAMERA" />
+
+    <uses-feature android:name="android.hardware.camera" />
+
+    <application android:allowBackup="true"
+        android:label="@string/app_name"
+        android:theme="@style/Theme.AppCompat">
+
+        <activity android:name=".MainActivity"
+                  android:label="@string/app_name"
+                  android:screenOrientation="portrait">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/MainActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/MainActivity.java
new file mode 100644
index 0000000..4261c2c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/MainActivity.java
@@ -0,0 +1,357 @@
+/*
+ * 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 androidx.camera.app.camera2interopburst;
+
+import android.Manifest;
+import android.app.Activity;
+import android.arch.lifecycle.LifecycleOwner;
+import android.arch.lifecycle.MutableLiveData;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CaptureRequest;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.app.AppCompatActivity;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.TextureView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import androidx.camera.camera2.Camera2Configuration;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.ImageAnalysisUseCase;
+import androidx.camera.core.ImageAnalysisUseCaseConfiguration;
+import androidx.camera.core.ImageProxy;
+import androidx.camera.core.ViewFinderUseCase;
+import androidx.camera.core.ViewFinderUseCaseConfiguration;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * An activity using CameraX-Camera2 interop to capture a burst.
+ *
+ * <p>First, the activity uses CameraX to set up a ViewFinderUseCase and ImageAnalysisUseCase. The
+ * ImageAnalysisUseCase converts Image instances into Bitmap instances. During the setup, custom
+ * CameraCaptureSession.StateCallback and CameraCaptureSession.CaptureCallback instances are passed
+ * to CameraX. These callbacks enable the activity to get references to the CameraCaptureSession and
+ * repeating CaptureRequest created internally by CameraX.
+ *
+ * <p>Then, when the user clicks on the viewfinder, CameraX's repeating request is stopped and a new
+ * burst capture request is issued, using the active CameraCaptureSession still owned by CameraX.
+ * The images captured during the burst are shown in an overlay mosaic for visualization.
+ *
+ * <p>Finally, after the burst capture concludes, CameraX's previous repeating request is resumed,
+ * until the next time the user starts a burst.
+ */
+@SuppressWarnings("AndroidJdkLibsChecker") // CompletableFuture not generally available yet.
+public class MainActivity extends AppCompatActivity {
+  private static final String TAG = MainActivity.class.getName();
+  private static final int CAMERA_REQUEST_CODE = 101;
+  private static final int BURST_FRAME_COUNT = 30;
+  private static final int MOSAIC_ROWS = 3;
+  private static final int MOSAIC_COLS = BURST_FRAME_COUNT / MOSAIC_ROWS;
+  // TODO: Figure out dynamically to fill the screen, instead of hard-coding.
+  private static final int TILE_WIDTH = 102;
+  private static final int TILE_HEIGHT = 72;;
+
+  // Waiting for the permissions approval.
+  private final CompletableFuture<Integer> completableFuture = new CompletableFuture<>();
+
+  // For handling touch events on the TextureView.
+  private final View.OnTouchListener  OnTouchListener();
+
+  // Tracks the burst state.
+  private final Object burstLock = new Object();
+
+  @GuardedBy("burstLock")
+  private boolean burstInProgress = false;
+
+  @GuardedBy("burstLock")
+  private int burstFrameCount = 0;
+
+  // Camera2 interop objects.
+  private final SessionUpdatingSessionStateCallback sessionStateCallback =
+      new SessionUpdatingSessionStateCallback();
+  private final RequestUpdatingSessionCaptureCallback sessionCaptureCallback =
+      new RequestUpdatingSessionCaptureCallback();
+
+  // For visualizing the images captured in the burst.
+  private ImageView imageView;
+  private final Object mosaicLock = new Object();
+
+  @GuardedBy("mosaicLock")
+  private final Bitmap mosaic =
+      Bitmap.createBitmap(
+          MOSAIC_COLS * TILE_WIDTH, MOSAIC_ROWS * TILE_HEIGHT, Bitmap.Config.ARGB_8888);
+
+  private final MutableLiveData<Bitmap> analysisResult = new MutableLiveData<>();
+
+  // For running ops on a background thread.
+  private Handler backgroundHandler;
+  private HandlerThread backgroundHandlerThread;
+
+  @Override
+  protected void onCreate(@Nullable Bundle bundle) {
+    super.onCreate(bundle);
+    setContentView(R.layout.activity_main);
+
+    backgroundHandlerThread = new HandlerThread("Background");
+    backgroundHandlerThread.start();
+    backgroundHandler = new Handler(backgroundHandlerThread.getLooper());
+
+    new Thread(() -> {
+      setupCamera();
+    }).start();
+    setupPermissions(this);
+  }
+
+  @Override
+  protected void onDestroy() {
+    super.onDestroy();
+    backgroundHandler.removeCallbacksAndMessages(null);
+    backgroundHandlerThread.quitSafely();
+  }
+
+  @Override
+  protected void onPause() {
+    synchronized (burstLock) {
+      burstInProgress = false;
+    }
+    super.onPause();
+  }
+
+  private void setupCamera() {
+    try {
+      // Wait for permissions before proceeding.
+      if (completableFuture.get() == PackageManager.PERMISSION_DENIED) {
+        Log.e(TAG, "Permission to open camera denied.");
+        return;
+      }
+    } catch (InterruptedException | ExecutionException e) {
+      Log.e(TAG, "Exception occurred getting permission future: " + e);
+    }
+    LifecycleOwner lifecycleOwner = this;
+
+    // Run this on the UI thread to manipulate the Textures & Views.
+    MainActivity.this.runOnUiThread(
+        () -> {
+          imageView = findViewById(R.id.imageView);
+
+          ViewFinderUseCaseConfiguration.Builder viewFinderConfigBuilder =
+              new ViewFinderUseCaseConfiguration.Builder().setTargetName("ViewFinder");
+
+          new Camera2Configuration.Extender(viewFinderConfigBuilder)
+              .setSessionStateCallback(sessionStateCallback)
+              .setSessionCaptureCallback(sessionCaptureCallback);
+
+          ViewFinderUseCaseConfiguration viewFinderConfig = viewFinderConfigBuilder.build();
+          TextureView textureView = findViewById(R.id.textureView);
+          textureView.setOnTouchListener(onTouchListener);
+          ViewFinderUseCase viewFinderUseCase = new ViewFinderUseCase(viewFinderConfig);
+
+          viewFinderUseCase.setOnViewFinderOutputUpdateListener(
+              output -> {
+                // If TextureView was already created, need to re-add it to change
+                // the SurfaceTexture.
+                ViewGroup v = (ViewGroup) textureView.getParent();
+                v.removeView(textureView);
+                v.addView(textureView);
+                textureView.setSurfaceTexture(output.getSurfaceTexture());
+              });
+
+          CameraX.bindToLifecycle(lifecycleOwner, viewFinderUseCase);
+
+          ImageAnalysisUseCaseConfiguration analysisConfig =
+              new ImageAnalysisUseCaseConfiguration.Builder()
+                  .setTargetName("ImageAnalysis")
+                  .setCallbackHandler(backgroundHandler)
+                  .build();
+          ImageAnalysisUseCase analysisUseCase = new ImageAnalysisUseCase(analysisConfig);
+          CameraX.bindToLifecycle(lifecycleOwner, analysisUseCase);
+          analysisUseCase.setAnalyzer(
+              (image, rotationDegrees) -> {
+                analysisResult.postValue(convertYuv420ImageToBitmap(image));
+              });
+          analysisResult.observe(
+              lifecycleOwner,
+              bitmap -> {
+                synchronized (burstLock) {
+                  if (burstInProgress) {
+                    // Update the mosaic.
+                    insertIntoMosaic(bitmap, burstFrameCount++);
+                    MainActivity.this.runOnUiThread(
+                        () -> {
+                          synchronized (mosaicLock) {
+                            imageView.setImageBitmap(mosaic);
+                          }
+                        });
+
+                    // Detect the end of the burst.
+                    if (burstFrameCount == BURST_FRAME_COUNT) {
+                      burstInProgress = false;
+                      submitRepeatingRequest();
+                    }
+                  }
+                }
+              });
+        });
+  }
+
+  private void setupPermissions(Activity context) {
+    int permission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA);
+    if (permission != PackageManager.PERMISSION_GRANTED) {
+      makePermissionRequest(context);
+    } else {
+      completableFuture.complete(permission);
+    }
+  }
+
+  private static void makePermissionRequest(Activity context) {
+    ActivityCompat.requestPermissions(context, new String[]{Manifest.permission.CAMERA},
+        CAMERA_REQUEST_CODE);
+  }
+
+  @Override
+  public void onRequestPermissionsResult(int requestCode,
+      String[] permissions, int[] grantResults) {
+    switch (requestCode) {
+      case CAMERA_REQUEST_CODE: {
+        // If request is cancelled, the result arrays are empty.
+        if (grantResults.length > 0
+            && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+          Log.i(TAG, "Camera Permission Granted.");
+        } else {
+          Log.i(TAG, "Camera Permission Denied.");
+        }
+          completableFuture.complete(grantResults[0]);
+        return;
+      }
+      default: {}
+    }
+  }
+
+  /** An on-touch listener which submits a capture burst request when the view is touched. */
+  private class OnTouchListener implements View.OnTouchListener {
+    @Override
+    public boolean onTouch(View view, MotionEvent event) {
+      if (event.getActionMasked() == MotionEvent.ACTION_UP) {
+        synchronized (burstLock) {
+          if (!burstInProgress) {
+            burstFrameCount = 0;
+            synchronized (mosaicLock) {
+              mosaic.eraseColor(0);
+            }
+            try {
+              sessionStateCallback.getSession().stopRepeating();
+            } catch (CameraAccessException e) {
+              throw new RuntimeException("Could not stop the repeating request.", e);
+            }
+            submitCaptureBurstRequest();
+            burstInProgress = true;
+          }
+        }
+      }
+      return true;
+    }
+  }
+
+  private void submitCaptureBurstRequest() {
+    try {
+      // Use the existing session created by CameraX.
+      CameraCaptureSession session = sessionStateCallback.getSession();
+      // Use the previous request created by CameraX.
+      CaptureRequest request = sessionCaptureCallback.getRequest();
+      List<CaptureRequest> requests = new ArrayList<>(BURST_FRAME_COUNT);
+      for (int i = 0; i < BURST_FRAME_COUNT; ++i) {
+        requests.add(request);
+      }
+      session.captureBurst(requests, /*callback=*/ null, backgroundHandler);
+    } catch (CameraAccessException e) {
+      throw new RuntimeException("Could not submit the burst capture request.", e);
+    }
+  }
+
+  private void submitRepeatingRequest() {
+    try {
+      // Use the existing session created by CameraX.
+      CameraCaptureSession session = sessionStateCallback.getSession();
+      // Use the previous request created by CameraX.
+      CaptureRequest request = sessionCaptureCallback.getRequest();
+      // TODO: This capture callback is not the same as that used by CameraX internally.
+      // Find a way to use exactly that same callback.
+      session.setRepeatingRequest(request, sessionCaptureCallback, backgroundHandler);
+    } catch (CameraAccessException e) {
+      throw new RuntimeException("Could not submit the repeating request.", e);
+    }
+  }
+
+  // TODO: Do proper YUV420-to-RGB conversion, instead of just taking the Y channel and
+  // propagating it to all 3 channels.
+  private Bitmap convertYuv420ImageToBitmap(ImageProxy image) {
+    ImageProxy.PlaneProxy plane = image.getPlanes()[0];
+    ByteBuffer buffer = plane.getBuffer();
+    final int bytesCount = buffer.remaining();
+    byte[] imageBytes = new byte[bytesCount];
+    buffer.get(imageBytes);
+
+    // TODO: Reuse a bitmap from a pool.
+    Bitmap bitmap =
+        Bitmap.createBitmap(image.getWidth(), image.getHeight(), Bitmap.Config.ARGB_8888);
+
+    int[] bitmapPixels = new int[bitmap.getWidth() * bitmap.getHeight()];
+    for (int row = 0; row < bitmap.getHeight(); ++row) {
+      int imageBytesPosition = row * plane.getRowStride();
+      int bitmapPixelsPosition = row * bitmap.getWidth();
+      for (int col = 0; col < bitmap.getWidth(); ++col) {
+        int channelValue = (imageBytes[imageBytesPosition++] & 0xFF);
+        bitmapPixels[bitmapPixelsPosition++] = Color.rgb(channelValue, channelValue, channelValue);
+      }
+    }
+    bitmap.setPixels(
+        bitmapPixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
+    return bitmap;
+  }
+
+  private void insertIntoMosaic(Bitmap bitmap, int position) {
+    // TODO: Reuse a bitmap from a pool.
+    Bitmap rescaledBitmap =
+        Bitmap.createScaledBitmap(bitmap, TILE_WIDTH, TILE_HEIGHT, /*filter=*/ false);
+
+    int tileRowOffset = (position / MOSAIC_COLS) * TILE_HEIGHT;
+    int tileColOffset = (position % MOSAIC_COLS) * TILE_WIDTH;
+    for (int row = 0; row < rescaledBitmap.getHeight(); ++row) {
+      for (int col = 0; col < rescaledBitmap.getWidth(); ++col) {
+        int color = rescaledBitmap.getPixel(col, row);
+        synchronized (mosaicLock) {
+          mosaic.setPixel(col + tileColOffset, row + tileRowOffset, color);
+        }
+      }
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/RequestUpdatingSessionCaptureCallback.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/RequestUpdatingSessionCaptureCallback.java
new file mode 100644
index 0000000..f5244b7
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/RequestUpdatingSessionCaptureCallback.java
@@ -0,0 +1,63 @@
+/*
+ * 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 androidx.camera.app.camera2interopburst;
+
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CaptureFailure;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.CaptureResult;
+import android.hardware.camera2.TotalCaptureResult;
+import android.view.Surface;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** A capture session capture callback which updates a reference to the capture request. */
+final class RequestUpdatingSessionCaptureCallback extends CameraCaptureSession.CaptureCallback {
+  private final AtomicReference<CaptureRequest> request = new AtomicReference<>();
+
+  @Override
+  public void onCaptureBufferLost(
+      CameraCaptureSession session, CaptureRequest request, Surface surface, long frame) {}
+
+  @Override
+  public void onCaptureCompleted(
+      CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {}
+
+  @Override
+  public void onCaptureFailed(
+      CameraCaptureSession session, CaptureRequest request, CaptureFailure failure) {}
+
+  @Override
+  public void onCaptureProgressed(
+      CameraCaptureSession session, CaptureRequest request, CaptureResult partialResult) {}
+
+  @Override
+  public void onCaptureSequenceAborted(CameraCaptureSession session, int sequenceId) {}
+
+  @Override
+  public void onCaptureSequenceCompleted(
+      CameraCaptureSession session, int sequenceId, long frame) {}
+
+  @Override
+  public void onCaptureStarted(
+      CameraCaptureSession session, CaptureRequest request, long timestamp, long frame) {
+    this.request.set(request);
+  }
+
+  CaptureRequest getRequest() {
+    return request.get();
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/SessionUpdatingSessionStateCallback.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/SessionUpdatingSessionStateCallback.java
new file mode 100644
index 0000000..c408944
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/SessionUpdatingSessionStateCallback.java
@@ -0,0 +1,86 @@
+/*
+ * 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 androidx.camera.app.camera2interopburst;
+
+import android.hardware.camera2.CameraCaptureSession;
+import android.support.annotation.GuardedBy;
+import android.util.Log;
+import android.view.Surface;
+
+/** A capture session state callback which updates a reference to the capture session. */
+final class SessionUpdatingSessionStateCallback extends CameraCaptureSession.StateCallback {
+  private static final String TAG = "SessionUpdatingSessionStateCallback";
+
+  private final Object sessionLock = new Object();
+
+  @GuardedBy("sessionLock")
+  private CameraCaptureSession session;
+
+  @Override
+  public void onConfigured(CameraCaptureSession session) {
+    Log.d(TAG, "onConfigured: session=" + session);
+    synchronized (sessionLock) {
+      this.session = session;
+    }
+  }
+
+  @Override
+  public void onActive(CameraCaptureSession session) {
+    Log.d(TAG, "onActive: session=" + session);
+  }
+
+  @Override
+  public void onClosed(CameraCaptureSession session) {
+    Log.d(TAG, "onClosed: session=" + session);
+    synchronized (sessionLock) {
+      if (this.session == session) {
+        this.session = null;
+      }
+    }
+  }
+
+  @Override
+  public void onReady(CameraCaptureSession session) {
+    Log.d(TAG, "onReady: session=" + session);
+  }
+
+  @Override
+  public void onCaptureQueueEmpty(CameraCaptureSession session) {
+    Log.d(TAG, "onCaptureQueueEmpty: session=" + session);
+  }
+
+  @Override
+  public void onSurfacePrepared(CameraCaptureSession session, Surface surface) {
+    Log.d(TAG, "onSurfacePrepared: session=" + session + ", surface=" + surface);
+  }
+
+  @Override
+  public void onConfigureFailed(CameraCaptureSession session) {
+    Log.d(TAG, "onConfigureFailed: session=" + session);
+    synchronized (sessionLock) {
+      if (this.session == session) {
+        this.session = null;
+      }
+    }
+  }
+
+  CameraCaptureSession getSession() {
+    synchronized (sessionLock) {
+      return session;
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/build.gradle b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/build.gradle
new file mode 100644
index 0000000..3b12af3
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+    compileOptions {
+        sourceCompatibility project.ext.javaVersion
+        targetCompatibility project.ext.javaVersion
+    }
+
+    compileSdkVersion project.ext.compileSdk
+
+    defaultConfig {
+        applicationId "androidx.camera.app.camera2interopburst"
+        minSdkVersion project.ext.minSdk
+        targetSdkVersion project.ext.targetSdk
+        versionCode 1
+        versionName project.ext.version
+        multiDexEnabled true
+    }
+
+    sourceSets {
+        main.manifest.srcFile 'AndroidManifest.xml'
+        main.java.srcDirs = ['.']
+        main.java.excludes = ['**/build/**']
+        main.java.includes = ['*.java']
+        main.res.srcDirs = ['res']
+    }
+
+    buildTypes {
+        debug {
+            testCoverageEnabled true
+        }
+
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt')
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    // Internal library
+    implementation project(':camera2')
+    implementation project(':core')
+
+    // Lifecycle and LiveData
+    implementation "android.arch.lifecycle:extensions:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:livedata:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:runtime:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:common-java8:${project.ext.lifecycleVersion}"
+
+    // Android Support Library
+    implementation "com.android.support:appcompat-v7:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support.constraint:constraint-layout:1.0.2"
+    implementation "com.android.support:support-compat:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-annotations:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-v13:${project.ext.supportLibraryVersion}"
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/layout/activity_main.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/layout/activity_main.xml
new file mode 100644
index 0000000..5a19a22
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/layout/activity_main.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+   xmlns:app="http://schemas.android.com/apk/res-auto"
+   xmlns:tools="http://schemas.android.com/tools"
+   android:layout_width="match_parent"
+   android:layout_height="match_parent"
+   app:layout_constraintHeight_min="640dp"
+   tools:context="androidx.camera.app.camera2interopburst.MainActivity">
+
+   <RelativeLayout
+       android:layout_width="match_parent"
+       android:layout_height="match_parent"
+       android:orientation="vertical">
+
+       <TextureView
+           android:id="@+id/textureView"
+           android:layout_width="match_parent"
+           android:layout_height="wrap_content"
+           android:minHeight="177dp" />
+
+       <ImageView
+           android:id="@+id/imageView"
+           android:elevation="2dp"
+           android:layout_width="wrap_content"
+           android:layout_height="wrap_content"
+           android:layout_marginTop="0dp"
+           android:layout_marginLeft="0dp" />
+   </RelativeLayout>
+
+</android.support.constraint.ConstraintLayout>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/values/strings.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/values/strings.xml
new file mode 100644
index 0000000..f88c72a
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interopburst/res/values/strings.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+  <string name="app_name">Camera2 Interop Burst</string>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/AndroidManifest.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/AndroidManifest.xml
new file mode 100644
index 0000000..54d7570
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/AndroidManifest.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="androidx.camera.app.camera2interoperror">
+  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27"/>
+
+  <uses-permission android:name="android.permission.CAMERA" />
+  <uses-feature android:name="android.hardware.camera" />
+
+  <application
+      android:name="androidx.camera.app.camera2interoperror.CameraXInteropErrorApplication"
+      android:theme="@style/AppTheme">
+    <activity
+        android:name="androidx.camera.app.camera2interoperror.CameraXInteropErrorActivity"
+        android:label="CameraX Camera2InteropError">
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN" />
+        <category android:name="android.intent.category.LAUNCHER" />
+      </intent-filter>
+    </activity>
+  </application>
+</manifest>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCase.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCase.java
new file mode 100644
index 0000000..f0b2606
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCase.java
@@ -0,0 +1,125 @@
+/*
+ * 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 androidx.camera.app.camera2interoperror;
+
+import android.graphics.ImageFormat;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraDevice.StateCallback;
+import android.media.ImageReader;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import android.util.Size;
+import androidx.camera.core.BaseUseCase;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.SessionConfiguration;
+import java.util.Collections;
+import java.util.Map;
+
+/** A use case which attempts to use camera2 calls directly in an erroneous manner. */
+public class Camera2InteropErrorUseCase extends BaseUseCase {
+  private static final String TAG = "Camera2InteropErrorUseCase";
+  private CameraDevice cameraDevice;
+  private ImageReader imageReader;
+  private final Camera2InteropErrorUseCaseConfiguration configuration;
+
+  private final CameraDevice.StateCallback stateCallback =
+      new StateCallback() {
+        @Override
+        public void onOpened(@NonNull CameraDevice camera) {
+          Log.d(TAG, "CameraDevice.StateCallback.onOpened()");
+          Camera2InteropErrorUseCase.this.cameraDevice = camera;
+        }
+
+        @Override
+        public void onDisconnected(@NonNull CameraDevice camera) {
+          Log.d(TAG, "CameraDevice.StateCallback.onDisconnected()");
+        }
+
+        @Override
+        public void onError(@NonNull CameraDevice camera, int error) {
+          Log.d(TAG, "CameraDevice.StateCallback.onError()");
+        }
+      };
+
+  private final CameraCaptureSession.StateCallback captureSessionStateCallback =
+      new CameraCaptureSession.StateCallback() {
+        @Override
+        public void onConfigured(@NonNull CameraCaptureSession session) {
+          Log.d(TAG, "CameraCaptureSession.StateCallback.onConfigured()");
+        }
+
+        @Override
+        public void onConfigureFailed(@NonNull CameraCaptureSession session) {
+          Log.d(TAG, "CameraCaptureSession.StateCallback.onConfigured()");
+        }
+      };
+
+  public Camera2InteropErrorUseCase(Camera2InteropErrorUseCaseConfiguration configuration) {
+    super(configuration);
+    this.configuration = configuration;
+  }
+
+  /** Closes the {@link CameraDevice} obtained via callback. */
+  void closeCamera() {
+    if (cameraDevice != null) {
+      Log.d(TAG, "Closing CameraDevice.");
+      cameraDevice.close();
+    } else {
+      Log.d(TAG, "No CameraDevice to close.");
+    }
+  }
+
+  /** Opens a {@link CameraCaptureSession} using the {@link CameraDevice} obtained via callback. */
+  void reopenCaptureSession() {
+    try {
+      Log.d(TAG, "Opening a CameraCaptureSession.");
+      cameraDevice.createCaptureSession(
+          Collections.singletonList(imageReader.getSurface()), captureSessionStateCallback, null);
+    } catch (CameraAccessException e) {
+      Log.e(TAG, "no permission to create capture session");
+    }
+  }
+
+  @Override
+  protected Map<String, Size> onSuggestedResolutionUpdated(
+      Map<String, Size> suggestedResolutionMap) {
+    imageReader = ImageReader.newInstance(640, 480, ImageFormat.YUV_420_888, 2);
+
+    imageReader.setOnImageAvailableListener(
+        imageReader -> {
+          imageReader.acquireNextImage().close();
+        },
+        null);
+
+    SessionConfiguration.Builder sessionConfigBuilder = new SessionConfiguration.Builder();
+    sessionConfigBuilder.clearSurfaces();
+    sessionConfigBuilder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    sessionConfigBuilder.setDeviceStateCallback(stateCallback);
+
+    try {
+      String cameraId = CameraX.getCameraWithLensFacing(configuration.getLensFacing());
+      attachToCamera(cameraId, sessionConfigBuilder.build());
+    } catch (Exception e) {
+      throw new IllegalArgumentException(
+          "Unable to attach to camera with LensFacing " + configuration.getLensFacing(), e);
+    }
+
+    return suggestedResolutionMap;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCaseConfiguration.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCaseConfiguration.java
new file mode 100644
index 0000000..1ec830c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/Camera2InteropErrorUseCaseConfiguration.java
@@ -0,0 +1,74 @@
+/*
+ * 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 androidx.camera.app.camera2interoperror;
+
+import androidx.camera.core.CameraDeviceConfiguration;
+import androidx.camera.core.Configuration;
+import androidx.camera.core.ImageOutputConfiguration;
+import androidx.camera.core.MutableConfiguration;
+import androidx.camera.core.MutableOptionsBundle;
+import androidx.camera.core.OptionsBundle;
+import androidx.camera.core.UseCaseConfiguration;
+
+/** Configuration for the camera 2 interop case configuration */
+public class Camera2InteropErrorUseCaseConfiguration
+    implements UseCaseConfiguration<Camera2InteropErrorUseCase>,
+        CameraDeviceConfiguration,
+        ImageOutputConfiguration {
+
+  private final Configuration config;
+
+  private Camera2InteropErrorUseCaseConfiguration(Configuration config) {
+    this.config = config;
+  }
+
+  @Override
+  public Configuration getConfiguration() {
+    return config;
+  }
+
+  /** Builder for an empty Configuration */
+  public static final class Builder
+      implements UseCaseConfiguration.Builder<
+              Camera2InteropErrorUseCase, Camera2InteropErrorUseCaseConfiguration, Builder>,
+          CameraDeviceConfiguration.Builder<Camera2InteropErrorUseCaseConfiguration, Builder>,
+          ImageOutputConfiguration.Builder<Camera2InteropErrorUseCaseConfiguration, Builder> {
+
+    private final MutableOptionsBundle optionsBundle;
+
+    public Builder() {
+      optionsBundle = MutableOptionsBundle.create();
+      setTargetClass(Camera2InteropErrorUseCase.class);
+    }
+
+    @Override
+    public MutableConfiguration getMutableConfiguration() {
+      return optionsBundle;
+    }
+
+    @Override
+    public Builder builder() {
+      return this;
+    }
+
+    @Override
+    public Camera2InteropErrorUseCaseConfiguration build() {
+      return new Camera2InteropErrorUseCaseConfiguration(OptionsBundle.from(optionsBundle));
+    }
+  }
+
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorActivity.java
new file mode 100644
index 0000000..df1066f
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorActivity.java
@@ -0,0 +1,287 @@
+/*
+ * 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 androidx.camera.app.camera2interoperror;
+
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.SurfaceTexture;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraDevice.StateCallback;
+import android.hardware.camera2.CameraManager;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.app.AppCompatActivity;
+import android.util.Log;
+import android.util.Rational;
+import android.view.TextureView;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.CameraX.LensFacing;
+import androidx.camera.core.ViewFinderUseCase;
+import androidx.camera.core.ViewFinderUseCaseConfiguration;
+import androidx.legacy.app.ActivityCompat;
+import java.util.concurrent.CompletableFuture;
+
+/** for testing interop */
+@SuppressWarnings("AndroidJdkLibsChecker") // CompletableFuture not generally available yet.
+public class CameraXInteropErrorActivity extends AppCompatActivity
+    implements ActivityCompat.OnRequestPermissionsResultCallback {
+  private static final String TAG = "CameraXInteropErrorActivity";
+  private static final int PERMISSIONS_REQUEST_CODE = 42;
+  private static final Rational ASPECT_RATIO = new Rational(4, 3);
+
+  private final CompletableFuture<Boolean> completableFuture = new CompletableFuture<>();
+
+  /** The LensFacing to use. */
+  private LensFacing currentCameraLensFacing = LensFacing.BACK;
+
+  private String currentCameraFacingString = "BACK";
+
+  /** The types of errors that can be induced on CameraX. */
+  enum ErrorType {
+    /** Attempt to reopen the currently opened {@link CameraDevice}. */
+    REOPEN_CAMERA,
+    /** Close the {@link CameraDevice} without going through CameraX. */
+    CLOSE_DEVICE,
+    /**
+     * Open up a {@link android.hardware.camera2.CameraCaptureSession} using the {@link
+     * CameraDevice} obtained from {@link CameraDevice.StateCallback}.
+     */
+    OPEN_CAPTURE_SESSION
+  }
+
+  private ErrorType currentError = ErrorType.REOPEN_CAMERA;
+
+  /**
+   * Creates a view finder use case.
+   *
+   * <p>This use case observes a {@link SurfaceTexture}. The texture is connected to a {@link
+   * TextureView} to display a camera preview.
+   */
+  private void createViewFinderUseCase() {
+    ViewFinderUseCaseConfiguration configuration =
+        new ViewFinderUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("ViewFinder")
+            .setTargetAspectRatio(ASPECT_RATIO)
+            .build();
+    ViewFinderUseCase viewFinderUseCase = new ViewFinderUseCase(configuration);
+
+    viewFinderUseCase.setOnViewFinderOutputUpdateListener(
+        viewFinderOutput -> {
+          // If TextureView was already created, need to re-add it to change the SurfaceTexture.
+          TextureView textureView = findViewById(R.id.textureView);
+          ViewGroup viewGroup = (ViewGroup) textureView.getParent();
+          viewGroup.removeView(textureView);
+          viewGroup.addView(textureView);
+          textureView.setSurfaceTexture(viewFinderOutput.getSurfaceTexture());
+        });
+
+    CameraX.bindToLifecycle(/* lifecycleOwner= */this, viewFinderUseCase);
+    Log.i(TAG, "Got UseCase: " + viewFinderUseCase);
+  }
+
+  void createBadUseCase() {
+    Camera2InteropErrorUseCaseConfiguration configuration =
+        new Camera2InteropErrorUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("Camera2InteropErrorUseCase")
+            .setTargetAspectRatio(ASPECT_RATIO)
+            .build();
+    Camera2InteropErrorUseCase camera2InteropErrorUseCase =
+        new Camera2InteropErrorUseCase(configuration);
+    CameraX.bindToLifecycle(this, camera2InteropErrorUseCase);
+
+    Button button = this.findViewById(R.id.CauseError);
+
+    button.setOnClickListener(
+        view -> {
+          switch (currentError) {
+            case CLOSE_DEVICE:
+              camera2InteropErrorUseCase.closeCamera();
+              break;
+            case REOPEN_CAMERA:
+              CameraManager manager =
+                  (CameraManager) getApplicationContext().getSystemService(CAMERA_SERVICE);
+              Log.d(TAG, "Attempting to reopen camera");
+              try {
+                String cameraId = CameraX.getCameraWithLensFacing(currentCameraLensFacing);
+                manager.openCamera(
+                    cameraId,
+                    new StateCallback() {
+                      @Override
+                      public void onOpened(@NonNull CameraDevice camera) {}
+
+                      @Override
+                      public void onDisconnected(@NonNull CameraDevice camera) {}
+
+                      @Override
+                      public void onError(@NonNull CameraDevice camera, int error) {}
+                    },
+                    null);
+                Log.d(TAG, "Looks like nothing overtly bad occurred");
+              } catch (Exception e) {
+                Log.e(TAG, e.getMessage());
+                Log.e(TAG, "Should we do something here?");
+              }
+              break;
+            case OPEN_CAPTURE_SESSION:
+              camera2InteropErrorUseCase.reopenCaptureSession();
+              break;
+          }
+        });
+
+    TextView textView = this.findViewById(R.id.textView);
+    textView.setText(currentError.toString());
+
+    Button button1 = this.findViewById(R.id.SelectError);
+    button1.setOnClickListener(
+        view -> {
+          switch (currentError) {
+            case CLOSE_DEVICE:
+              currentError = ErrorType.REOPEN_CAMERA;
+              break;
+            case REOPEN_CAMERA:
+              currentError = ErrorType.OPEN_CAPTURE_SESSION;
+              break;
+            case OPEN_CAPTURE_SESSION:
+              currentError = ErrorType.CLOSE_DEVICE;
+              break;
+          }
+          textView.setText(currentError.toString());
+        });
+  }
+
+  /** Creates all the use cases. */
+  private void createUseCases() {
+    createViewFinderUseCase();
+    createBadUseCase();
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.activity_camera_2main);
+
+    // Get params from adb extra string
+    Bundle bundle = this.getIntent().getExtras();
+    if (bundle != null) {
+      currentCameraFacingString = bundle.getString("cameraFacing");
+    }
+
+    new Thread(
+            () -> {
+              setupCamera();
+            })
+        .start();
+    setupPermissions();
+  }
+
+  private void setupCamera() {
+    try {
+      // Wait for permissions before proceeding.
+      if (!completableFuture.get()) {
+        Log.d(TAG, "Permissions denied.");
+        return;
+      }
+    } catch (Exception e) {
+      Log.e(TAG, "Exception occurred getting permission future: " + e);
+    }
+
+    Log.d(TAG, "Camera Facing: " + currentCameraFacingString);
+    if (currentCameraFacingString.equalsIgnoreCase("BACK")) {
+      currentCameraLensFacing = LensFacing.BACK;
+    } else if (currentCameraFacingString.equalsIgnoreCase("FRONT")) {
+      currentCameraLensFacing = LensFacing.FRONT;
+    } else {
+      throw new RuntimeException("Invalid lens facing: " + currentCameraFacingString);
+    }
+
+    Log.d(TAG, "Using camera lens facing: " + currentCameraLensFacing);
+
+    // Run this on the UI thread to manipulate the Textures & Views.
+    CameraXInteropErrorActivity.this.runOnUiThread(
+        () -> {
+          createUseCases();
+        });
+  }
+
+  private void setupPermissions() {
+    if (!allPermissionsGranted()) {
+      makePermissionRequest();
+    } else {
+      completableFuture.complete(true);
+    }
+  }
+
+  private void makePermissionRequest() {
+    ActivityCompat.requestPermissions(this, getRequiredPermissions(), PERMISSIONS_REQUEST_CODE);
+  }
+
+  /** Returns true if all the necessary permissions have been granted already. */
+  private boolean allPermissionsGranted() {
+    for (String permission : getRequiredPermissions()) {
+      if (ContextCompat.checkSelfPermission(this, permission)
+          != PackageManager.PERMISSION_GRANTED) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Tries to acquire all the necessary permissions through a dialog. */
+  private String[] getRequiredPermissions() {
+    PackageInfo info;
+    try {
+      info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_PERMISSIONS);
+    } catch (NameNotFoundException exception) {
+      Log.e(TAG, "Failed to obtain all required permissions.", exception);
+      return new String[0];
+    }
+    String[] permissions = info.requestedPermissions;
+    if (permissions != null && permissions.length > 0) {
+      return permissions;
+    } else {
+      return new String[0];
+    }
+  }
+
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {
+    switch (requestCode) {
+      case PERMISSIONS_REQUEST_CODE:
+        {
+          // If request is cancelled, the result arrays are empty.
+          if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+            Log.d(TAG, "Permissions Granted.");
+            completableFuture.complete(true);
+          } else {
+            Log.d(TAG, "Permissions Denied.");
+            completableFuture.complete(false);
+          }
+          return;
+      }
+      default:
+        // No-op
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorApplication.java b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorApplication.java
new file mode 100644
index 0000000..759be10
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/CameraXInteropErrorApplication.java
@@ -0,0 +1,29 @@
+/*
+ * 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 androidx.camera.app.camera2interoperror;
+
+import android.app.Application;
+
+/** An application for CameraX. */
+public class CameraXInteropErrorApplication extends Application {
+
+  @Override
+  public void onCreate() {
+    super.onCreate();
+  }
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/build.gradle b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/build.gradle
new file mode 100644
index 0000000..9f471fc
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+    compileOptions {
+        sourceCompatibility project.ext.javaVersion
+        targetCompatibility project.ext.javaVersion
+    }
+
+    compileSdkVersion project.ext.compileSdk
+
+    defaultConfig {
+        applicationId "androidx.camera.app.camera2interoperror"
+        minSdkVersion project.ext.minSdk
+        targetSdkVersion project.ext.targetSdk
+        versionCode 1
+        versionName project.ext.version
+        multiDexEnabled true
+    }
+
+    sourceSets {
+        main.manifest.srcFile 'AndroidManifest.xml'
+        main.java.srcDirs = ['.']
+        main.java.excludes = ['**/build/**']
+        main.java.includes = ['*.java']
+        main.res.srcDirs = ['res']
+    }
+
+    buildTypes {
+        debug {
+            testCoverageEnabled true
+        }
+
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt')
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    // Internal library
+    implementation project(':camera2')
+    implementation project(':core')
+
+    // Lifecycle and LiveData
+    implementation "android.arch.lifecycle:extensions:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:livedata:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:runtime:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:common-java8:${project.ext.lifecycleVersion}"
+
+    // Android Support Library
+    implementation "com.android.support:appcompat-v7:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support.constraint:constraint-layout:1.0.2"
+    implementation "com.android.support:support-compat:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-annotations:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-v13:${project.ext.supportLibraryVersion}"
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/layout/activity_camera_2main.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/layout/activity_camera_2main.xml
new file mode 100644
index 0000000..55366dc
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/layout/activity_camera_2main.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<android.support.constraint.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/constraintLayout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="androidx.camera.app.camera2interoperror.CameraXInteropErrorActivity">
+
+  <TextureView
+      android:id="@+id/textureView"
+      android:layout_width="0dp"
+      android:layout_height="0dp"
+      app:layout_constraintBottom_toBottomOf="parent"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintStart_toStartOf="parent"/>
+
+  <TextView
+      android:id="@+id/textView"
+      android:elevation="2dp"
+      android:layout_width="0dp"
+      android:layout_height="0dp"
+      android:background="#FFF"
+      android:src="@android:drawable/btn_radio"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintDimensionRatio="4:3"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="1.0"
+      app:layout_constraintStart_toStartOf="@+id/guideline"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0.84000003"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/guideline"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.7"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/selecterror"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.1"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/causeerror"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.4" />
+
+  <Button
+      android:id="@+id/SelectError"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Select Error"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.0"
+      app:layout_constraintStart_toStartOf="@+id/selecterror"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0.84000003" />
+
+  <Button
+      android:id="@+id/CauseError"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Cause Error"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.0"
+      app:layout_constraintStart_toStartOf="@+id/causeerror"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0.84000003" />
+</android.support.constraint.ConstraintLayout>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/strings.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/strings.xml
new file mode 100644
index 0000000..2df0565
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/strings.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/style.xml b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/style.xml
new file mode 100644
index 0000000..0c1afa6
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/camera2interoperror/res/values/style.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+  <!-- Base application theme. -->
+  <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+    <!-- Customize your theme here. -->
+  </style>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/AndroidManifest.xml b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/AndroidManifest.xml
new file mode 100644
index 0000000..deeaa4e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/AndroidManifest.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="androidx.camera.app.hellocamerax">
+  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27"/>
+
+  <uses-permission android:name="android.permission.CAMERA" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.RECORD_AUDIO" />
+  <uses-feature android:name="android.hardware.camera" />
+
+  <application
+      android:name="androidx.camera.app.hellocamerax.CameraXApplication"
+      android:theme="@style/AppTheme">
+    <activity
+        android:name="androidx.camera.app.hellocamerax.CameraXActivity"
+        android:label="Hello CameraX">
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN" />
+        <category android:name="android.intent.category.LAUNCHER" />
+      </intent-filter>
+    </activity>
+  </application>
+</manifest>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXActivity.java
new file mode 100644
index 0000000..da52ca4
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXActivity.java
@@ -0,0 +1,680 @@
+/*
+ * 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 androidx.camera.app.hellocamerax;
+
+import android.arch.lifecycle.MutableLiveData;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.SurfaceTexture;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.StrictMode;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.app.AppCompatActivity;
+import android.util.Log;
+import android.util.Size;
+import android.view.Surface;
+import android.view.TextureView;
+import android.view.TextureView.SurfaceTextureListener;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.camera.core.BaseUseCase;
+import androidx.camera.core.CameraDeviceConfiguration;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.CameraX.LensFacing;
+import androidx.camera.core.ImageAnalysisUseCase;
+import androidx.camera.core.ImageAnalysisUseCaseConfiguration;
+import androidx.camera.core.ImageCaptureUseCase;
+import androidx.camera.core.ImageCaptureUseCaseConfiguration;
+import androidx.camera.core.VideoCaptureUseCase;
+import androidx.camera.core.VideoCaptureUseCaseConfiguration;
+import androidx.camera.core.ViewFinderUseCase;
+import androidx.camera.core.ViewFinderUseCaseConfiguration;
+import androidx.legacy.app.ActivityCompat;
+import java.io.File;
+import java.math.BigDecimal;
+import java.text.Format;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.concurrent.Callable;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * An activity with four use cases: (1) view finder, (2) image capture, (3) image analysis, (4)
+ * video capture.
+ *
+ * <p>All four use cases are created with CameraX and tied to the activity's lifecycle. CameraX
+ * automatically connects and disconnects the use cases from the camera in response to changes in
+ * the activity's lifecycle. Therefore, the use cases function properly when the app is paused and
+ * resumed and when the device is rotated. The complex interactions between the camera and these
+ * lifecycle events are handled internally by CameraX.
+ */
+public class CameraXActivity extends AppCompatActivity
+    implements ActivityCompat.OnRequestPermissionsResultCallback {
+  private static final String TAG = "CameraXActivity";
+  private static final int PERMISSIONS_REQUEST_CODE = 42;
+  // Possible values for this intent key: "backward" or "forward".
+  private static final String INTENT_EXTRA_CAMERA_DIRECTION = "camera_direction";
+
+  private final SettableCallable<Boolean> settableResult = new SettableCallable<>();
+  private final FutureTask<Boolean> completableFuture = new FutureTask<>(settableResult);
+  private final AtomicLong imageAnalysisFrameCount = new AtomicLong(0);
+  private VideoFileSaver videoFileSaver;
+
+  /** The cameraId to use. Assume that 0 is the typical back facing camera. */
+  private LensFacing currentCameraLensFacing = LensFacing.BACK;
+
+  private String currentCameraDirection = "BACKWARD";
+
+  // TODO: Move the analysis processing, capture processing to separate threads, so
+  // there is smaller impact on the preview.
+
+  private ViewFinderUseCase viewFinderUseCase;
+  private ImageAnalysisUseCase imageAnalysisUseCase;
+  private ImageCaptureUseCase imageCaptureUseCase;
+  private VideoCaptureUseCase videoCaptureUseCase;
+
+  private final MutableLiveData<String> imageAnalysisResult = new MutableLiveData<>();
+
+  /**
+   * Creates a view finder use case.
+   *
+   * <p>This use case observes a {@link SurfaceTexture}. The texture is connected to a {@link
+   * TextureView} to display a camera preview.
+   */
+  private void createViewFinderUseCase() {
+    Button button = this.findViewById(R.id.PreviewToggle);
+    button.setBackgroundColor(Color.LTGRAY);
+    enableViewFinderUseCase();
+
+    button.setOnClickListener(
+        view -> {
+          Button buttonView = (Button) view;
+          if (viewFinderUseCase != null) {
+            // Remove the use case
+            buttonView.setBackgroundColor(Color.RED);
+            CameraX.unbind(viewFinderUseCase);
+            viewFinderUseCase = null;
+          } else {
+            // Add the use case
+            buttonView.setBackgroundColor(Color.LTGRAY);
+
+            enableViewFinderUseCase();
+          }
+        });
+
+    Log.i(TAG, "Got UseCase: " + viewFinderUseCase);
+  }
+
+  void enableViewFinderUseCase() {
+    ViewFinderUseCaseConfiguration configuration =
+        new ViewFinderUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("ViewFinder")
+            .build();
+
+    viewFinderUseCase = new ViewFinderUseCase(configuration);
+    TextureView textureView = findViewById(R.id.textureView);
+    viewFinderUseCase.setOnViewFinderOutputUpdateListener(
+        viewFinderOutput -> {
+          // If TextureView was already created, need to re-add it to change the SurfaceTexture.
+          ViewGroup viewGroup = (ViewGroup) textureView.getParent();
+          viewGroup.removeView(textureView);
+          viewGroup.addView(textureView);
+          textureView.setSurfaceTexture(viewFinderOutput.getSurfaceTexture());
+        });
+
+    if (!bindToLifecycleSafely(viewFinderUseCase, R.id.PreviewToggle)) {
+      viewFinderUseCase = null;
+      return;
+    }
+
+    transformPreview();
+
+    textureView.setSurfaceTextureListener(new SurfaceTextureListener() {
+      @Override
+      public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int i, int i1) {
+
+      }
+
+      @Override
+      public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int i, int i1) {
+        transformPreview();
+      }
+
+      @Override
+      public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) {
+        return false;
+      }
+
+      @Override
+      public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {
+
+      }
+    });
+
+  }
+
+  void transformPreview() {
+    String cameraId = null;
+    LensFacing viewFinderLensFacing =
+        ((CameraDeviceConfiguration) viewFinderUseCase.getUseCaseConfiguration())
+            .getLensFacing(/*valueIfMissing=*/ null);
+    if (viewFinderLensFacing != currentCameraLensFacing) {
+      throw new IllegalStateException(
+          "Invalid view finder lens facing: "
+              + viewFinderLensFacing
+              + " Should be: "
+              + currentCameraLensFacing);
+    }
+    try {
+      cameraId = CameraX.getCameraWithLensFacing(viewFinderLensFacing);
+    } catch (Exception e) {
+      throw new IllegalArgumentException(
+          "Unable to get camera id for lens facing " + viewFinderLensFacing, e);
+    }
+    Size srcResolution = viewFinderUseCase.getAttachedSurfaceResolution(cameraId);
+
+    if (srcResolution.getWidth() == 0 || srcResolution.getHeight() == 0) {
+      return;
+    }
+
+    TextureView textureView = this.findViewById(R.id.textureView);
+
+    if (textureView.getWidth() == 0 || textureView.getHeight() == 0) {
+      return;
+    }
+
+    Matrix matrix = new Matrix();
+
+    int left = textureView.getLeft();
+    int right = textureView.getRight();
+    int top = textureView.getTop();
+    int bottom = textureView.getBottom();
+
+    // Compute the viewfinder ui size based on the available width, height, and ui orientation.
+    int viewWidth = (right - left);
+    int viewHeight = (bottom - top);
+
+    int displayRotation = getDisplayRotation();
+    Size scaled =
+        calculateViewfinderViewDimens(srcResolution, viewWidth, viewHeight, displayRotation);
+
+    // Compute the center of the view.
+    int centerX = viewWidth / 2;
+    int centerY = viewHeight / 2;
+
+    // Do corresponding rotation to correct the preview direction
+    matrix.postRotate(-getDisplayRotation(), centerX, centerY);
+
+    // Compute the scale value for center crop mode
+    float xScale = scaled.getWidth() / (float) viewWidth;
+    float yScale = scaled.getHeight() / (float) viewHeight;
+
+    if (getDisplayRotation() == 90 || getDisplayRotation() == 270) {
+      xScale = scaled.getWidth() / (float) viewHeight;
+      yScale = scaled.getHeight() / (float) viewWidth;
+    }
+
+    // Only two digits after the decimal point are valid for postScale. Need to get ceiling of two
+    // digits floating value to do the scale operation. Otherwise, the result may be scaled not
+    // large enough and will have some blank lines on the screen.
+    xScale = new BigDecimal(xScale).setScale(2, BigDecimal.ROUND_CEILING).floatValue();
+    yScale = new BigDecimal(yScale).setScale(2, BigDecimal.ROUND_CEILING).floatValue();
+
+    // Do corresponding scale to resolve the deformation problem
+    matrix.postScale(xScale, yScale, centerX, centerY);
+
+    // Compute the new left/top positions to do translate
+    int layoutL = centerX - (scaled.getWidth() / 2);
+    int layoutT = centerY - (scaled.getHeight() / 2);
+
+    // Do corresponding translation to be center crop
+    matrix.postTranslate(layoutL, layoutT);
+
+    textureView.setTransform(matrix);
+  }
+
+  /** @return One of 0, 90, 180, 270. */
+  private int getDisplayRotation() {
+    int displayRotation = getWindowManager().getDefaultDisplay().getRotation();
+
+    switch (displayRotation) {
+      case Surface.ROTATION_0:
+        displayRotation = 0;
+        break;
+      case Surface.ROTATION_90:
+        displayRotation = 90;
+        break;
+      case Surface.ROTATION_180:
+        displayRotation = 180;
+        break;
+      case Surface.ROTATION_270:
+        displayRotation = 270;
+        break;
+      default:
+        throw new UnsupportedOperationException("Unsupported display rotation: " + displayRotation);
+    }
+
+    return displayRotation;
+  }
+
+  private Size calculateViewfinderViewDimens(
+      Size srcSize, int parentWidth, int parentHeight, int displayRotation) {
+    int inWidth = srcSize.getWidth();
+    int inHeight = srcSize.getHeight();
+    if (displayRotation == 0 || displayRotation == 180) {
+      // Need to reverse the width and height since we're in landscape orientation.
+      inWidth = srcSize.getHeight();
+      inHeight = srcSize.getWidth();
+    }
+
+    int outWidth = parentWidth;
+    int outHeight = parentHeight;
+    if (inWidth != 0 && inHeight != 0) {
+      float vfRatio = inWidth / (float) inHeight;
+      float parentRatio = parentWidth / (float) parentHeight;
+
+      // Match shortest sides together.
+      if (vfRatio < parentRatio) {
+        outWidth = parentWidth;
+        outHeight = Math.round(parentWidth / vfRatio);
+      } else {
+        outWidth = Math.round(parentHeight * vfRatio);
+        outHeight = parentHeight;
+      }
+    }
+
+    return new Size(outWidth, outHeight);
+  }
+
+  /**
+   * Creates an image analysis use case.
+   *
+   * <p>This use case observes a stream of analysis results computed from the frames.
+   */
+  private void createImageAnalysisUseCase() {
+    Button button = this.findViewById(R.id.AnalysisToggle);
+    button.setBackgroundColor(Color.LTGRAY);
+    enableImageAnalysisUseCase();
+
+    button.setOnClickListener(
+        view -> {
+          Button buttonView = (Button) view;
+          if (imageAnalysisUseCase != null) {
+            // Remove the use case
+            buttonView.setBackgroundColor(Color.RED);
+            CameraX.unbind(imageAnalysisUseCase);
+            imageAnalysisUseCase = null;
+          } else {
+            // Add the use case
+            buttonView.setBackgroundColor(Color.LTGRAY);
+            enableImageAnalysisUseCase();
+          }
+        });
+
+    Log.i(TAG, "Got UseCase: " + imageAnalysisUseCase);
+  }
+
+  void enableImageAnalysisUseCase() {
+    ImageAnalysisUseCaseConfiguration configuration =
+        new ImageAnalysisUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("ImageAnalysis")
+            .setCallbackHandler(new Handler(Looper.getMainLooper()))
+            .build();
+
+    imageAnalysisUseCase = new ImageAnalysisUseCase(configuration);
+
+    TextView textView = this.findViewById(R.id.textView);
+
+    if (!bindToLifecycleSafely(imageAnalysisUseCase, R.id.AnalysisToggle)) {
+      imageAnalysisUseCase = null;
+      return;
+    }
+
+    imageAnalysisUseCase.setAnalyzer(
+        (image, rotationDegrees) -> {
+          // Since we set the callback handler to a main thread handler, we can call setValue()
+          // here. If we weren't on the main thread, we would have to call postValue() instead.
+          imageAnalysisResult.setValue(Long.toString(image.getTimestamp()));
+        });
+    imageAnalysisResult.observe(
+        this,
+        text -> {
+          if (imageAnalysisFrameCount.getAndIncrement() % 30 == 0) {
+            textView.setText("ImgCount: " + imageAnalysisFrameCount.get() + " @ts: " + text);
+          }
+        });
+  }
+
+  /**
+   * Creates an image capture use case.
+   *
+   * <p>This use case takes a picture and saves it to a file, whenever the user clicks a button.
+   */
+  private void createImageCaptureUseCase() {
+
+    Button button = this.findViewById(R.id.PhotoToggle);
+    button.setBackgroundColor(Color.LTGRAY);
+    enableImageCaptureUseCase();
+
+    button.setOnClickListener(
+        view -> {
+          Button buttonView = (Button) view;
+          if (imageCaptureUseCase != null) {
+            // Remove the use case
+            buttonView.setBackgroundColor(Color.RED);
+            disableImageCaptureUseCase();
+          } else {
+            // Add the use case
+            buttonView.setBackgroundColor(Color.LTGRAY);
+            enableImageCaptureUseCase();
+          }
+        });
+
+    Log.i(TAG, "Got UseCase: " + imageCaptureUseCase);
+  }
+
+  void enableImageCaptureUseCase() {
+    ImageCaptureUseCaseConfiguration configuration =
+        new ImageCaptureUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("ImageCapture")
+            .build();
+
+    imageCaptureUseCase = new ImageCaptureUseCase(configuration);
+
+    if (!bindToLifecycleSafely(imageCaptureUseCase, R.id.PhotoToggle)) {
+      Button button = this.findViewById(R.id.Picture);
+      button.setOnClickListener(null);
+      imageCaptureUseCase = null;
+      return;
+    }
+
+    Button button = this.findViewById(R.id.Picture);
+    final Format formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS");
+    final File dir = this.getExternalFilesDir(null);
+    button.setOnClickListener(
+        view -> {
+          imageCaptureUseCase.takePicture(
+              new File(dir, formatter.format(Calendar.getInstance().getTime()) + ".jpg"),
+              new ImageCaptureUseCase.OnImageSavedListener() {
+                @Override
+                public void onImageSaved(File file) {
+                  Log.d(TAG, "Saved image to " + file);
+                }
+
+                @Override
+                public void onError(
+                    ImageCaptureUseCase.UseCaseError useCaseError,
+                    String message,
+                    Throwable cause) {
+                  Log.e(TAG, "Failed to save image.", cause);
+                }
+              });
+        });
+  }
+
+  void disableImageCaptureUseCase() {
+    CameraX.unbind(imageCaptureUseCase);
+
+    imageCaptureUseCase = null;
+    Button button = this.findViewById(R.id.Picture);
+    button.setOnClickListener(null);
+  }
+
+  /**
+   * Creates a video capture use case.
+   *
+   * <p>This use case records a video segment and saves it to a file, in response to user button
+   * clicks.
+   */
+  private void createVideoCaptureUseCase() {
+    Button button = this.findViewById(R.id.VideoToggle);
+    button.setBackgroundColor(Color.LTGRAY);
+    enableVideoCaptureUseCase();
+
+    videoFileSaver = new VideoFileSaver();
+    videoFileSaver.setRootDirectory(
+        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM));
+
+    button.setOnClickListener(
+        view -> {
+          Button buttonView = (Button) view;
+          if (videoCaptureUseCase != null) {
+            // Remove the use case
+            buttonView.setBackgroundColor(Color.RED);
+            disableVideoCaptureUseCase();
+          } else {
+            // Add the use case
+            buttonView.setBackgroundColor(Color.LTGRAY);
+            enableVideoCaptureUseCase();
+          }
+        });
+
+    Log.i(TAG, "Got UseCase: " + videoCaptureUseCase);
+  }
+
+  void enableVideoCaptureUseCase() {
+    VideoCaptureUseCaseConfiguration configuration =
+        new VideoCaptureUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("VideoCapture")
+            .build();
+
+    videoCaptureUseCase = new VideoCaptureUseCase(configuration);
+
+    if (!bindToLifecycleSafely(videoCaptureUseCase, R.id.VideoToggle)) {
+      Button button = this.findViewById(R.id.Video);
+      button.setOnClickListener(null);
+      videoCaptureUseCase = null;
+      return;
+    }
+
+    Button button = this.findViewById(R.id.Video);
+    button.setOnClickListener(
+        view -> {
+          Button buttonView = (Button) view;
+          String text = button.getText().toString();
+          if (text.equals("Record") && !videoFileSaver.isSaving()) {
+            videoCaptureUseCase.startRecording(videoFileSaver.getNewVideoFile(), videoFileSaver);
+            videoFileSaver.setSaving();
+            buttonView.setText("Stop");
+          } else if (text.equals("Stop") && videoFileSaver.isSaving()) {
+            buttonView.setText("Record");
+            videoCaptureUseCase.stopRecording();
+          } else if (text.equals("Record") && videoFileSaver.isSaving()) {
+            buttonView.setText("Stop");
+            videoFileSaver.setSaving();
+          } else if (text.equals("Stop") && !videoFileSaver.isSaving()) {
+            buttonView.setText("Record");
+          }
+        });
+  }
+
+  void disableVideoCaptureUseCase() {
+    Button button = this.findViewById(R.id.Video);
+    button.setOnClickListener(null);
+    CameraX.unbind(videoCaptureUseCase);
+
+    videoCaptureUseCase = null;
+  }
+
+  /** Creates all the use cases. */
+  private void createUseCases() {
+    createImageCaptureUseCase();
+    createViewFinderUseCase();
+    createImageAnalysisUseCase();
+    createVideoCaptureUseCase();
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.activity_camera_xmain);
+
+    StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build();
+    StrictMode.setVmPolicy(policy);
+
+    // Get params from adb extra string
+    Bundle bundle = this.getIntent().getExtras();
+    if (bundle != null) {
+      String newCameraDirection = bundle.getString(INTENT_EXTRA_CAMERA_DIRECTION);
+      if (newCameraDirection != null) {
+        currentCameraDirection = newCameraDirection;
+      }
+    }
+
+    new Thread(
+            () -> {
+              setupCamera();
+            })
+        .start();
+    setupPermissions();
+  }
+
+  private void setupCamera() {
+    try {
+      // Wait for permissions before proceeding.
+      if (!completableFuture.get()) {
+        Log.d(TAG, "Permissions denied.");
+        return;
+      }
+    } catch (Exception e) {
+      Log.e(TAG, "Exception occurred getting permission future: " + e);
+    }
+
+    Log.d(TAG, "Camera direction: " + currentCameraDirection);
+    if (currentCameraDirection.equalsIgnoreCase("BACKWARD")) {
+      currentCameraLensFacing = LensFacing.BACK;
+    } else if (currentCameraDirection.equalsIgnoreCase("FORWARD")) {
+      currentCameraLensFacing = LensFacing.FRONT;
+    } else {
+      throw new RuntimeException("Invalid camera direction: " + currentCameraDirection);
+    }
+    Log.d(TAG, "Using camera lens facing: " + currentCameraLensFacing);
+
+    // Run this on the UI thread to manipulate the Textures & Views.
+    CameraXActivity.this.runOnUiThread(
+        () -> {
+          createUseCases();
+        });
+  }
+
+  private void setupPermissions() {
+    if (!allPermissionsGranted()) {
+      makePermissionRequest();
+    } else {
+      settableResult.set(true);
+      completableFuture.run();
+    }
+  }
+
+  private void makePermissionRequest() {
+    ActivityCompat.requestPermissions(this, getRequiredPermissions(), PERMISSIONS_REQUEST_CODE);
+  }
+
+  /** Returns true if all the necessary permissions have been granted already. */
+  private boolean allPermissionsGranted() {
+    for (String permission : getRequiredPermissions()) {
+      if (ContextCompat.checkSelfPermission(this, permission)
+          != PackageManager.PERMISSION_GRANTED) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Tries to acquire all the necessary permissions through a dialog. */
+  private String[] getRequiredPermissions() {
+    PackageInfo info;
+    try {
+      info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_PERMISSIONS);
+    } catch (NameNotFoundException exception) {
+      Log.e(TAG, "Failed to obtain all required permissions.", exception);
+      return new String[0];
+    }
+    String[] permissions = info.requestedPermissions;
+    if (permissions != null && permissions.length > 0) {
+      return permissions;
+    } else {
+      return new String[0];
+    }
+  }
+
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {
+    switch (requestCode) {
+      case PERMISSIONS_REQUEST_CODE:
+        {
+          // If request is cancelled, the result arrays are empty.
+          if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+            Log.d(TAG, "Permissions Granted.");
+            settableResult.set(true);
+            completableFuture.run();
+          } else {
+            Log.d(TAG, "Permissions Denied.");
+            settableResult.set(false);
+            completableFuture.run();
+          }
+          return;
+      }
+      default:
+        // No-op
+    }
+  }
+
+  /** A {@link Callable} whose return value can be set. */
+  private static final class SettableCallable<V> implements Callable<V> {
+    private final AtomicReference<V> value = new AtomicReference<>();
+
+    public void set(V value) {
+      this.value.set(value);
+    }
+
+    @Override
+    public V call() {
+      return value.get();
+    }
+  }
+
+  private boolean bindToLifecycleSafely(BaseUseCase useCase, int buttonViewId) {
+    try {
+      CameraX.bindToLifecycle(this, useCase);
+    } catch (IllegalArgumentException e) {
+      Log.e(TAG, e.getMessage());
+      Toast.makeText(getApplicationContext(), "Bind too many use cases.", Toast.LENGTH_SHORT)
+          .show();
+      Button button = this.findViewById(buttonViewId);
+      button.setBackgroundColor(Color.RED);
+      return false;
+    }
+
+    return true;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXApplication.java b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXApplication.java
new file mode 100644
index 0000000..60db9b9
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/CameraXApplication.java
@@ -0,0 +1,29 @@
+/*
+ * 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 androidx.camera.app.hellocamerax;
+
+import android.app.Application;
+
+/** An application for CameraX. */
+public class CameraXApplication extends Application {
+
+  @Override
+  public void onCreate() {
+    super.onCreate();
+  }
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/VideoFileSaver.java b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/VideoFileSaver.java
new file mode 100644
index 0000000..ecf0554
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/VideoFileSaver.java
@@ -0,0 +1,90 @@
+/*
+ * 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 androidx.camera.app.hellocamerax;
+
+import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import androidx.camera.core.VideoCaptureUseCase.OnVideoSavedListener;
+import androidx.camera.core.VideoCaptureUseCase.UseCaseError;
+import java.io.File;
+import java.text.Format;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Basic functionality required for interfacing the {@link
+ * androidx.camera.core.VideoCaptureUseCase}.
+ */
+public class VideoFileSaver implements OnVideoSavedListener {
+  private static final String TAG = "VideoFileSaver";
+  private File rootDirectory;
+  private final Format formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.ENGLISH);
+
+  private final Object lock = new Object();
+  @GuardedBy("lock")
+  private boolean isSaving = false;
+
+  @Override
+  public void onVideoSaved(File file) {
+
+    Log.d(TAG, "Saved file: " + file.getPath());
+    synchronized (lock) {
+      isSaving = false;
+    }
+  }
+
+  @Override
+  public void onError(UseCaseError useCaseError, String message, @Nullable Throwable cause) {
+
+    Log.e(TAG, "Error: " + useCaseError + ", " + message);
+    if (cause != null) {
+      Log.e(TAG, "Error cause: " + cause.getCause());
+    }
+
+    synchronized (lock) {
+      isSaving = false;
+    }
+  }
+
+  /** Returns a new {@link File} where to save a video. */
+  public File getNewVideoFile() {
+    Date date = Calendar.getInstance().getTime();
+    File file = new File(rootDirectory + "/" + formatter.format(date) + ".mp4");
+    return file;
+  }
+
+  /** Sets the directory for saving files. */
+  public void setRootDirectory(File rootDirectory) {
+    this.rootDirectory = rootDirectory;
+  }
+
+  boolean isSaving() {
+    synchronized (lock) {
+      return isSaving;
+    }
+  }
+
+  /** Sets saving state after video startRecording */
+  void setSaving() {
+    synchronized (lock) {
+      isSaving = true;
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/build.gradle b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/build.gradle
new file mode 100644
index 0000000..09c6dd0
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+    compileOptions {
+        sourceCompatibility project.ext.javaVersion
+        targetCompatibility project.ext.javaVersion
+    }
+
+    compileSdkVersion project.ext.compileSdk
+
+    defaultConfig {
+        applicationId "androidx.camera.app.hellocamerax"
+        minSdkVersion project.ext.minSdk
+        targetSdkVersion project.ext.targetSdk
+        versionCode 1
+        versionName project.ext.version
+        multiDexEnabled true
+    }
+
+    sourceSets {
+        main.manifest.srcFile 'AndroidManifest.xml'
+        main.java.srcDirs = ['.']
+        main.java.excludes = ['**/build/**']
+        main.java.includes = ['*.java']
+        main.res.srcDirs = ['res']
+    }
+
+    buildTypes {
+        debug {
+            testCoverageEnabled true
+        }
+
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt')
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    // Internal library
+    implementation project(':camera2')
+    implementation project(':core')
+
+    // Lifecycle and LiveData
+    implementation "android.arch.lifecycle:extensions:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:livedata:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:runtime:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:common-java8:${project.ext.lifecycleVersion}"
+
+    // Android Support Library
+    implementation "com.android.support:appcompat-v7:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support.constraint:constraint-layout:1.0.2"
+    implementation "com.android.support:support-compat:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-annotations:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-v13:${project.ext.supportLibraryVersion}"
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/layout/activity_camera_xmain.xml b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/layout/activity_camera_xmain.xml
new file mode 100644
index 0000000..a37b6ab
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/layout/activity_camera_xmain.xml
@@ -0,0 +1,155 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<android.support.constraint.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/constraintLayout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="androidx.camera.app.CameraXActivity">
+
+  <TextureView
+      android:id="@+id/textureView"
+      android:layout_width="0dp"
+      android:layout_height="0dp"
+      app:layout_constraintBottom_toBottomOf="parent"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintStart_toStartOf="parent"/>
+
+  <TextView
+      android:id="@+id/textView"
+      android:elevation="2dp"
+      android:layout_width="0dp"
+      android:layout_height="0dp"
+      android:background="#FFF"
+      android:src="@android:drawable/btn_radio"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintDimensionRatio="4:3"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="1.0"
+      app:layout_constraintStart_toStartOf="@+id/guideline"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="1.0"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/guideline"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.7"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/takepicture"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.1"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/takevideo"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.4" />
+
+  <Button
+      android:id="@+id/Picture"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Picture"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.0"
+      app:layout_constraintStart_toStartOf="@+id/takepicture"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="1.0" />
+
+  <Button
+      android:id="@+id/Video"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Record"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.0"
+      app:layout_constraintStart_toStartOf="@+id/takevideo"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="1.0" />
+
+  <Button
+      android:id="@+id/VideoToggle"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Video"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0"
+      app:layout_constraintStart_toStartOf="@+id/constraintLayout"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0"
+      />
+
+  <Button
+      android:id="@+id/PhotoToggle"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Photo"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.333"
+      app:layout_constraintStart_toStartOf="@+id/constraintLayout"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0"
+      />
+
+  <Button
+      android:id="@+id/AnalysisToggle"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Analysis"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.666"
+      app:layout_constraintStart_toStartOf="@+id/constraintLayout"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0"
+      />
+
+  <Button
+      android:id="@+id/PreviewToggle"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Preview"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="1.0"
+      app:layout_constraintStart_toStartOf="@+id/constraintLayout"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="0"
+      />
+</android.support.constraint.ConstraintLayout>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/strings.xml b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/strings.xml
new file mode 100644
index 0000000..d4eaf3c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/strings.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources/>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/style.xml b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/style.xml
new file mode 100644
index 0000000..0c1afa6
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/hellocamerax/res/values/style.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+  <!-- Base application theme. -->
+  <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+    <!-- Customize your theme here. -->
+  </style>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/AndroidManifest.xml b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/AndroidManifest.xml
new file mode 100644
index 0000000..b085f64
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/AndroidManifest.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="androidx.camera.app.javacameraxpermissions">
+
+    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27" />
+
+    <uses-permission android:name="android.permission.CAMERA" />
+
+    <uses-feature android:name="android.hardware.camera" />
+
+    <application android:allowBackup="true"
+        android:label="@string/app_name"
+        android:theme="@style/Theme.AppCompat">
+
+        <activity android:name="androidx.camera.app.javacameraxpermissions.MainActivity"
+                  android:label="@string/app_name">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/MainActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/MainActivity.java
new file mode 100644
index 0000000..517c7a7
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/MainActivity.java
@@ -0,0 +1,115 @@
+/*
+ * 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 androidx.camera.app.javacameraxpermissions;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.app.AppCompatActivity;
+import android.util.Log;
+import android.view.TextureView;
+import android.view.ViewGroup;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.ViewFinderUseCase;
+import androidx.camera.core.ViewFinderUseCaseConfiguration;
+import java.util.concurrent.CompletableFuture;
+
+@SuppressWarnings("AndroidJdkLibsChecker")
+public class MainActivity extends AppCompatActivity {
+  private final CompletableFuture<Integer> cf = new CompletableFuture<>();
+  private static final String TAG = MainActivity.class.getName();
+  private static final int CAMERA_REQUEST_CODE = 101;
+
+  @Override
+  protected void onCreate(@Nullable Bundle bundle) {
+    super.onCreate(bundle);
+    setContentView(R.layout.activity_main);
+    new Thread(() -> {
+      setupCamera();
+    }).start();
+    setupPermissions(this);
+  }
+
+  private void setupCamera() {
+    try {
+      // Wait for permissions before proceeding.
+      if (cf.get() == PackageManager.PERMISSION_DENIED) {
+        Log.i(TAG, "Permission to open camera denied.");
+        return;
+      }
+    } catch (Exception e) {
+      Log.i(TAG, "Exception occurred getting permission future: " + e);
+    }
+
+    // Run this on the UI thread to manipulate the Textures & Views.
+    MainActivity.this.runOnUiThread(
+        () -> {
+          ViewFinderUseCaseConfiguration vfConfig =
+              new ViewFinderUseCaseConfiguration.Builder().setTargetName("vf0").build();
+          TextureView textureView = findViewById(R.id.textureView);
+          ViewFinderUseCase vfUseCase = new ViewFinderUseCase(vfConfig);
+
+          vfUseCase.setOnViewFinderOutputUpdateListener(
+              viewFinderOutput -> {
+                // If TextureView was already created, need to re-add it to change
+                // the SurfaceTexture.
+                ViewGroup v = (ViewGroup) textureView.getParent();
+                v.removeView(textureView);
+                v.addView(textureView);
+                textureView.setSurfaceTexture(viewFinderOutput.getSurfaceTexture());
+              });
+          CameraX.bindToLifecycle(/* lifecycleOwner= */this, vfUseCase);
+        });
+  }
+
+  private void setupPermissions(Activity context) {
+    int permission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA);
+    if (permission != PackageManager.PERMISSION_GRANTED) {
+      makePermissionRequest(context);
+    } else {
+      cf.complete(permission);
+    }
+  }
+
+  private static void makePermissionRequest(Activity context) {
+    ActivityCompat.requestPermissions(context, new String[]{Manifest.permission.CAMERA},
+        CAMERA_REQUEST_CODE);
+  }
+
+  @Override
+  public void onRequestPermissionsResult(int requestCode,
+      String[] permissions, int[] grantResults) {
+    switch (requestCode) {
+      case CAMERA_REQUEST_CODE: {
+        // If request is cancelled, the result arrays are empty.
+        if (grantResults.length > 0
+            && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+          Log.i(TAG, "Camera Permission Granted.");
+        } else {
+          Log.i(TAG, "Camera Permission Denied.");
+        }
+        cf.complete(grantResults[0]);
+        return;
+      }
+      default: {}
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/build.gradle b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/build.gradle
new file mode 100644
index 0000000..2316300
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+    compileOptions {
+        sourceCompatibility project.ext.javaVersion
+        targetCompatibility project.ext.javaVersion
+    }
+
+    compileSdkVersion project.ext.compileSdk
+
+    defaultConfig {
+        applicationId "androidx.camera.app.javacameraxpermissions"
+        minSdkVersion project.ext.minSdk
+        targetSdkVersion project.ext.targetSdk
+        versionCode 1
+        versionName project.ext.version
+        multiDexEnabled true
+    }
+
+    sourceSets {
+        main.manifest.srcFile 'AndroidManifest.xml'
+        main.java.srcDirs = ['.']
+        main.java.excludes = ['**/build/**']
+        main.java.includes = ['*.java']
+        main.res.srcDirs = ['res']
+    }
+
+    buildTypes {
+        debug {
+            testCoverageEnabled true
+        }
+
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt')
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    // Internal library
+    implementation project(':camera2')
+    implementation project(':core')
+
+    // Lifecycle and LiveData
+    implementation "android.arch.lifecycle:extensions:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:livedata:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:runtime:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:common-java8:${project.ext.lifecycleVersion}"
+
+    // Android Support Library
+    implementation "com.android.support:appcompat-v7:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support.constraint:constraint-layout:1.0.2"
+    implementation "com.android.support:support-compat:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-annotations:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-v13:${project.ext.supportLibraryVersion}"
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/layout/activity_main.xml b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/layout/activity_main.xml
new file mode 100644
index 0000000..dced21a
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/layout/activity_main.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+ <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+     xmlns:app="http://schemas.android.com/apk/res-auto"
+     xmlns:tools="http://schemas.android.com/tools"
+     android:layout_width="match_parent"
+     android:layout_height="match_parent"
+     app:layout_constraintHeight_min="640dp"
+     tools:context="androidx.camera.app.javacameraxpermissions.MainActivity">
+
+     <LinearLayout
+         android:layout_width="match_parent"
+         android:layout_height="match_parent"
+         android:orientation="vertical">
+
+         <TextureView
+             android:id="@+id/textureView"
+             android:layout_width="match_parent"
+             android:layout_height="wrap_content"
+             android:minHeight="177dp" />
+     </LinearLayout>
+
+ </android.support.constraint.ConstraintLayout>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/values/strings.xml b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/values/strings.xml
new file mode 100644
index 0000000..c954827
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/javacameraxpermissions/res/values/strings.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+  <string name="app_name">CameraXPermissions</string>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/AndroidManifest.xml b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/AndroidManifest.xml
new file mode 100644
index 0000000..3d112dd
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/AndroidManifest.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="androidx.camera.app.timingapp">
+  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27"/>
+
+  <uses-permission android:name="android.permission.CAMERA" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.RECORD_AUDIO" />
+  <uses-feature android:name="android.hardware.camera" />
+
+  <application android:theme="@style/AppTheme">
+    <activity
+        android:name="androidx.camera.app.timingapp.TakePhotoActivity"
+        android:label="Taking Photo">
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN" />
+        <category android:name="android.intent.category.LAUNCHER" />
+      </intent-filter>
+    </activity>
+  </application>
+</manifest>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/BaseActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/BaseActivity.java
new file mode 100644
index 0000000..8b0084c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/BaseActivity.java
@@ -0,0 +1,69 @@
+/*
+ * 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 androidx.camera.app.timingapp;
+
+import android.os.Bundle;
+import android.support.v7.app.AppCompatActivity;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * An activity used to run performance test case.
+ *
+ * <p>To run performance test case, please implement this Activity. Camerax Use Case can be
+ * implement in prepareUseCase and runUseCase. For performance result, you can set currentTimeMillis
+ * to startTime and store the execution time into totalTime. At the end of test case, please call
+ * onUseCaseFinish() to notify the lock.
+ */
+public abstract class BaseActivity extends AppCompatActivity {
+  private static final String TAG = "BaseActivity";
+
+  public long startTime;
+  public long totalTime;
+
+  public long openCameraStartTime;
+  public long openCameraTotalTime;
+
+  public long startRreviewTime;
+  public long startPreviewTotalTime;
+
+  public long previewFrameRate;
+
+  public long closeCameraStartTime;
+  public long closeCameraTotalTime;
+
+  public String imageResolution;
+
+  public CountDownLatch latch;
+
+  public static final long MICROS_IN_SECOND = TimeUnit.SECONDS.toMillis(1);
+  public static final long PREVIEW_FILL_BUFFER_TIME = 1500;
+
+  public abstract void prepareUseCase();
+
+  public abstract void runUseCase() throws InterruptedException;
+
+  public void onUseCaseFinish() {
+    latch.countDown();
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    latch = new CountDownLatch(1);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/CustomLifecycle.java b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/CustomLifecycle.java
new file mode 100644
index 0000000..9ed751d
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/CustomLifecycle.java
@@ -0,0 +1,61 @@
+/*
+ * 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 androidx.camera.app.timingapp;
+
+import android.arch.lifecycle.Lifecycle;
+import android.arch.lifecycle.Lifecycle.State;
+import android.arch.lifecycle.LifecycleOwner;
+import android.arch.lifecycle.LifecycleRegistry;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.NonNull;
+
+/**
+ * A customized lifecycle owner which obeys the lifecycle transition rules.
+ */
+public final class CustomLifecycle implements LifecycleOwner {
+  private final LifecycleRegistry lifecycleRegistry;
+  private final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+  public CustomLifecycle() {
+    lifecycleRegistry = new LifecycleRegistry(this);
+    lifecycleRegistry.markState(Lifecycle.State.INITIALIZED);
+    lifecycleRegistry.markState(Lifecycle.State.CREATED);
+  }
+
+  @NonNull
+  @Override
+  public Lifecycle getLifecycle() {
+    return lifecycleRegistry;
+  }
+
+  public void doOnResume() {
+    if (Looper.getMainLooper() != Looper.myLooper()) {
+      mainHandler.post(() -> doOnResume());
+      return;
+    }
+    lifecycleRegistry.markState(State.RESUMED);
+  }
+
+  public void doDestroyed() {
+    if (Looper.getMainLooper() != Looper.myLooper()) {
+      mainHandler.post(() -> doDestroyed());
+      return;
+    }
+    lifecycleRegistry.markState(State.DESTROYED);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/TakePhotoActivity.java b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/TakePhotoActivity.java
new file mode 100644
index 0000000..3b00d13
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/TakePhotoActivity.java
@@ -0,0 +1,268 @@
+/*
+ * 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 androidx.camera.app.timingapp;
+
+import android.graphics.SurfaceTexture;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraDevice;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.TextureView;
+import android.view.TextureView.SurfaceTextureListener;
+import android.view.ViewGroup;
+import android.widget.Button;
+import androidx.camera.camera2.Camera2Configuration;
+import androidx.camera.core.CameraX;
+import androidx.camera.core.CameraX.LensFacing;
+import androidx.camera.core.ImageCaptureUseCase;
+import androidx.camera.core.ImageCaptureUseCase.CaptureMode;
+import androidx.camera.core.ImageCaptureUseCaseConfiguration;
+import androidx.camera.core.ImageProxy;
+import androidx.camera.core.ViewFinderUseCase;
+import androidx.camera.core.ViewFinderUseCaseConfiguration;
+import com.google.common.base.Ascii;
+
+/** This Activity is used to run image capture performance test in mobileharness. */
+public class TakePhotoActivity extends BaseActivity {
+
+  private static final String TAG = "TakePhotoActivity";
+
+  /** The default cameraId to use. */
+  private LensFacing currentCameraLensFacing = LensFacing.BACK;
+
+  private final String defaultCameraFacing = CAMERA_FACING_BACK;
+
+  // How many sample frames we should use to calculate framerate.
+  private static final int FRAMERATE_SAMPLE_WINDOW = 5;
+
+  private static final String EXTRA_CAPTURE_MODE = "capture_mode";
+  private static final String EXTRA_CAMERA_FACING = "camera_facing";
+  private static final String CAMERA_FACING_FRONT = "FRONT";
+  private static final String CAMERA_FACING_BACK = "BACK";
+
+  private ImageCaptureUseCase imageCaptureUseCase;
+  private ViewFinderUseCase viewFinderUseCase;
+
+  private int frameCount;
+  private long previewSampleStartTime;
+  private CaptureMode captureMode = CaptureMode.MIN_LATENCY;
+  private CustomLifecycle customLifecycle;
+
+  @Override
+  public void runUseCase() throws InterruptedException {
+
+    // Length of time to let the preview stream run before capturing the first image.
+    // This can help ensure capture latency is real latency and not merely the device
+    // filling the buffer.
+    Thread.sleep(PREVIEW_FILL_BUFFER_TIME);
+
+    startTime = System.currentTimeMillis();
+    imageCaptureUseCase.takePicture(
+        new ImageCaptureUseCase.OnImageCapturedListener() {
+          @Override
+          public void onCaptureSuccess(ImageProxy image, int rotationDegrees) {
+            totalTime = System.currentTimeMillis() - startTime;
+            if (image != null) {
+              imageResolution = image.getWidth() + "x" + image.getHeight();
+            } else {
+              Log.e(TAG, "[onCaptureSuccess] image is null");
+            }
+          }
+        });
+  }
+
+  @Override
+  public void prepareUseCase() {
+    createViewFinderUseCase();
+    createImageCaptureUseCase();
+  }
+
+  void createViewFinderUseCase() {
+    ViewFinderUseCaseConfiguration.Builder configurationBuilder =
+        new ViewFinderUseCaseConfiguration.Builder()
+            .setLensFacing(currentCameraLensFacing)
+            .setTargetName("ViewFinder");
+
+    new Camera2Configuration.Extender(configurationBuilder)
+        .setDeviceStateCallback(deviceStateCallback)
+        .setSessionStateCallback(captureSessionStateCallback);
+
+    viewFinderUseCase = new ViewFinderUseCase(configurationBuilder.build());
+    openCameraStartTime = System.currentTimeMillis();
+
+    viewFinderUseCase.setOnViewFinderOutputUpdateListener(
+        viewFinderOutput -> {
+          TextureView textureView = this.findViewById(R.id.textureView);
+          ViewGroup viewGroup = (ViewGroup) textureView.getParent();
+          viewGroup.removeView(textureView);
+          viewGroup.addView(textureView);
+          textureView.setSurfaceTexture(viewFinderOutput.getSurfaceTexture());
+          textureView.setSurfaceTextureListener(
+              new SurfaceTextureListener() {
+                @Override
+                public void onSurfaceTextureAvailable(
+                    SurfaceTexture surfaceTexture, int i, int i1) {}
+
+                @Override
+                public void onSurfaceTextureSizeChanged(
+                    SurfaceTexture surfaceTexture, int i, int i1) {}
+
+                @Override
+                public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) {
+                  return false;
+                }
+
+                @Override
+                public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {
+                  Log.d(TAG, "[onSurfaceTextureUpdated]");
+                  if (0 == totalTime) {
+                    return;
+                  }
+
+                  if (0 == frameCount) {
+                    previewSampleStartTime = System.currentTimeMillis();
+                  } else if (FRAMERATE_SAMPLE_WINDOW == frameCount) {
+                    final long duration = System.currentTimeMillis() - previewSampleStartTime;
+                    previewFrameRate = (MICROS_IN_SECOND * FRAMERATE_SAMPLE_WINDOW / duration);
+                    closeCameraStartTime = System.currentTimeMillis();
+                    customLifecycle.doDestroyed();
+                  }
+                  frameCount++;
+                }
+              });
+        });
+
+    CameraX.bindToLifecycle(customLifecycle, viewFinderUseCase);
+  }
+
+  void createImageCaptureUseCase() {
+    ImageCaptureUseCaseConfiguration configuration =
+        new ImageCaptureUseCaseConfiguration.Builder()
+            .setTargetName("ImageCapture")
+            .setLensFacing(currentCameraLensFacing)
+            .setCaptureMode(captureMode)
+            .build();
+
+    imageCaptureUseCase = new ImageCaptureUseCase(configuration);
+    CameraX.bindToLifecycle(customLifecycle, imageCaptureUseCase);
+
+    final Button button = this.findViewById(R.id.Picture);
+    button.setOnClickListener(
+        view -> {
+          startTime = System.currentTimeMillis();
+          imageCaptureUseCase.takePicture(
+              new ImageCaptureUseCase.OnImageCapturedListener() {
+                @Override
+                public void onCaptureSuccess(ImageProxy image, int rotationDegrees) {
+                  totalTime = System.currentTimeMillis() - startTime;
+                  if (image != null) {
+                    imageResolution = image.getWidth() + "x" + image.getHeight();
+                  } else {
+                    Log.e(TAG, "[onCaptureSuccess] image is null");
+                  }
+                }
+              });
+        });
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.activity_main);
+
+    final Bundle bundle = getIntent().getExtras();
+    if (bundle != null) {
+      final String captureModeString = bundle.getString(EXTRA_CAPTURE_MODE);
+      if (captureModeString != null) {
+        captureMode = CaptureMode.valueOf(Ascii.toUpperCase(captureModeString));
+      }
+      final String cameraLensFacing = bundle.getString(EXTRA_CAMERA_FACING);
+      if (cameraLensFacing != null) {
+        setupCamera(cameraLensFacing);
+      } else {
+        setupCamera(defaultCameraFacing);
+      }
+    }
+    customLifecycle = new CustomLifecycle();
+    prepareUseCase();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    customLifecycle.doOnResume();
+  }
+
+  void setupCamera(String cameraFacing) {
+    Log.d(TAG, "Camera Facing: " + cameraFacing);
+    if (Ascii.equalsIgnoreCase(cameraFacing, CAMERA_FACING_BACK)) {
+      currentCameraLensFacing = LensFacing.BACK;
+    } else if (Ascii.equalsIgnoreCase(cameraFacing, CAMERA_FACING_FRONT)) {
+      currentCameraLensFacing = LensFacing.FRONT;
+    } else {
+      throw new RuntimeException("Invalid lens facing: " + cameraFacing);
+    }
+  }
+
+  private final CameraDevice.StateCallback deviceStateCallback =
+      new CameraDevice.StateCallback() {
+
+        @Override
+        public void onOpened(CameraDevice cameraDevice) {
+          openCameraTotalTime = System.currentTimeMillis() - openCameraStartTime;
+          Log.d(TAG, "[onOpened] openCameraTotalTime: " + openCameraTotalTime);
+          startRreviewTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onClosed(CameraDevice camera) {
+          super.onClosed(camera);
+          closeCameraTotalTime = System.currentTimeMillis() - closeCameraStartTime;
+          Log.d(TAG, "[onClosed] closeCameraTotalTime: " + closeCameraTotalTime);
+          onUseCaseFinish();
+        }
+
+        @Override
+        public void onDisconnected(CameraDevice cameraDevice) {}
+
+        @Override
+        public void onError(CameraDevice cameraDevice, int i) {
+          Log.e(TAG, "[onError] open camera failed, error code: " + i);
+        }
+      };
+
+  private final CameraCaptureSession.StateCallback captureSessionStateCallback =
+      new CameraCaptureSession.StateCallback() {
+
+        @Override
+        public void onActive(CameraCaptureSession session) {
+          super.onActive(session);
+          startPreviewTotalTime = System.currentTimeMillis() - startRreviewTime;
+          Log.d(TAG, "[onActive] previewStartTotalTime: " + startPreviewTotalTime);
+        }
+
+        @Override
+        public void onConfigured(CameraCaptureSession cameraCaptureSession) {
+          Log.d(TAG, "[onConfigured] CaptureSession configured!");
+        }
+
+        @Override
+        public void onConfigureFailed(CameraCaptureSession cameraCaptureSession) {
+          Log.e(TAG, "[onConfigureFailed] CameraX preview initialization failed.");
+        }
+      };
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/build.gradle b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/build.gradle
new file mode 100644
index 0000000..412e3ec
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+    compileOptions {
+        sourceCompatibility project.ext.javaVersion
+        targetCompatibility project.ext.javaVersion
+    }
+
+    compileSdkVersion project.ext.compileSdk
+
+    defaultConfig {
+        applicationId "androidx.camera.app.timingapp"
+        minSdkVersion project.ext.minSdk
+        targetSdkVersion project.ext.targetSdk
+        versionCode 1
+        versionName project.ext.version
+        multiDexEnabled true
+    }
+
+    sourceSets {
+        main.manifest.srcFile 'AndroidManifest.xml'
+        main.java.srcDirs = ['.']
+        main.java.excludes = ['**/build/**']
+        main.java.includes = ['*.java']
+        main.res.srcDirs = ['res']
+    }
+
+    buildTypes {
+        debug {
+            testCoverageEnabled true
+        }
+
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt')
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    // Internal library
+    implementation project(':camera2')
+    implementation project(':core')
+
+    // Lifecycle and LiveData
+    implementation "android.arch.lifecycle:extensions:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:livedata:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:runtime:${project.ext.lifecycleVersion}"
+    implementation "android.arch.lifecycle:common-java8:${project.ext.lifecycleVersion}"
+
+    // Android Support Library
+    implementation "com.android.support:appcompat-v7:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support.constraint:constraint-layout:1.0.2"
+    implementation "com.android.support:support-compat:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-annotations:${project.ext.supportLibraryVersion}"
+    implementation "com.android.support:support-v13:${project.ext.supportLibraryVersion}"
+}
+
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/layout/activity_main.xml b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/layout/activity_main.xml
new file mode 100644
index 0000000..193f84c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/layout/activity_main.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="androidx.camera.app.timingapp">
+
+  <TextureView
+      android:id="@+id/textureView"
+      android:layout_width="0dp"
+      android:layout_height="0dp"
+      app:layout_constraintBottom_toBottomOf="parent"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintStart_toStartOf="parent"/>
+
+  <android.support.constraint.Guideline
+      android:id="@+id/takepicture"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:orientation="vertical"
+      app:layout_constraintGuide_begin="0dp"
+      app:layout_constraintGuide_percent="0.1"/>
+
+  <Button
+      android:id="@+id/Picture"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Picture"
+      app:layout_constraintBottom_toBottomOf="parent"
+      android:scaleType="fitXY"
+      app:layout_constraintEnd_toEndOf="parent"
+      app:layout_constraintHorizontal_bias="0.0"
+      app:layout_constraintStart_toStartOf="@+id/takepicture"
+      app:layout_constraintTop_toTopOf="parent"
+      app:layout_constraintVertical_bias="1.0" />
+</android.support.constraint.ConstraintLayout>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/strings.xml b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/strings.xml
new file mode 100644
index 0000000..2df0565
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/strings.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/style.xml b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/style.xml
new file mode 100644
index 0000000..0c1afa6
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/app/timingapp/res/values/style.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<resources>
+  <!-- Base application theme. -->
+  <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+    <!-- Customize your theme here. -->
+  </style>
+</resources>
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageProxyAndroidTest.java
new file mode 100644
index 0000000..5b7f18c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageProxyAndroidTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.graphics.Rect;
+import android.media.Image;
+import androidx.test.runner.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class AndroidImageProxyAndroidTest {
+  private static final long INITIAL_TIMESTAMP = 138990020L;
+
+  private final Image image = mock(Image.class);
+  private final Image.Plane yPlane = mock(Image.Plane.class);
+  private final Image.Plane uPlane = mock(Image.Plane.class);
+  private final Image.Plane vPlane = mock(Image.Plane.class);
+  private ImageProxy imageProxy;
+
+  @Before
+  public void setUp() {
+    when(image.getPlanes()).thenReturn(new Image.Plane[] {yPlane, uPlane, vPlane});
+    when(yPlane.getRowStride()).thenReturn(640);
+    when(yPlane.getPixelStride()).thenReturn(1);
+    when(yPlane.getBuffer()).thenReturn(ByteBuffer.allocateDirect(640 * 480));
+    when(uPlane.getRowStride()).thenReturn(320);
+    when(uPlane.getPixelStride()).thenReturn(1);
+    when(uPlane.getBuffer()).thenReturn(ByteBuffer.allocateDirect(320 * 240));
+    when(vPlane.getRowStride()).thenReturn(320);
+    when(vPlane.getPixelStride()).thenReturn(1);
+    when(vPlane.getBuffer()).thenReturn(ByteBuffer.allocateDirect(320 * 240));
+
+    when(image.getTimestamp()).thenReturn(INITIAL_TIMESTAMP);
+    imageProxy = new AndroidImageProxy(image);
+  }
+
+  @Test
+  public void close_closesWrappedImage() {
+    imageProxy.close();
+
+    verify(image).close();
+  }
+
+  @Test
+  public void getCropRect_returnsCropRectForWrappedImage() {
+    when(image.getCropRect()).thenReturn(new Rect(0, 0, 20, 20));
+
+    assertThat(imageProxy.getCropRect()).isEqualTo(new Rect(0, 0, 20, 20));
+  }
+
+  @Test
+  public void setCropRect_setsCropRectForWrappedImage() {
+    imageProxy.setCropRect(new Rect(0, 0, 40, 40));
+
+    verify(image).setCropRect(new Rect(0, 0, 40, 40));
+  }
+
+  @Test
+  public void getFormat_returnsFormatForWrappedImage() {
+    when(image.getFormat()).thenReturn(ImageFormat.YUV_420_888);
+
+    assertThat(imageProxy.getFormat()).isEqualTo(ImageFormat.YUV_420_888);
+  }
+
+  @Test
+  public void getHeight_returnsHeightForWrappedImage() {
+    when(image.getHeight()).thenReturn(480);
+
+    assertThat(imageProxy.getHeight()).isEqualTo(480);
+  }
+
+  @Test
+  public void getWidth_returnsWidthForWrappedImage() {
+    when(image.getWidth()).thenReturn(640);
+
+    assertThat(imageProxy.getWidth()).isEqualTo(640);
+  }
+
+  @Test
+  public void getTimestamp_returnsTimestampForWrappedImage() {
+    assertThat(imageProxy.getTimestamp()).isEqualTo(INITIAL_TIMESTAMP);
+  }
+
+  public void setTimestamp_setsTimestampForWrappedImage() {
+    imageProxy.setTimestamp(INITIAL_TIMESTAMP + 10);
+
+    assertThat(imageProxy.getTimestamp()).isEqualTo(INITIAL_TIMESTAMP + 10);
+  }
+
+  @Test
+  public void getPlanes_returnsPlanesForWrappedImage() {
+    ImageProxy.PlaneProxy[] wrappedPlanes = imageProxy.getPlanes();
+
+    Image.Plane[] originalPlanes = new Image.Plane[] {yPlane, uPlane, vPlane};
+    assertThat(wrappedPlanes.length).isEqualTo(3);
+    for (int i = 0; i < 3; ++i) {
+      assertThat(wrappedPlanes[i].getRowStride()).isEqualTo(originalPlanes[i].getRowStride());
+      assertThat(wrappedPlanes[i].getPixelStride()).isEqualTo(originalPlanes[i].getPixelStride());
+      assertThat(wrappedPlanes[i].getBuffer()).isEqualTo(originalPlanes[i].getBuffer());
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageReaderProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageReaderProxyAndroidTest.java
new file mode 100644
index 0000000..b46451e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/AndroidImageReaderProxyAndroidTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import android.media.ImageReader;
+import android.os.Handler;
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+@RunWith(AndroidJUnit4.class)
+public final class AndroidImageReaderProxyAndroidTest {
+  private final ImageReader imageReader = mock(ImageReader.class);
+  private ImageReaderProxy imageReaderProxy;
+
+  @Before
+  public void setUp() {
+    imageReaderProxy = new AndroidImageReaderProxy(imageReader);
+    when(imageReader.acquireLatestImage()).thenReturn(mock(Image.class));
+    when(imageReader.acquireNextImage()).thenReturn(mock(Image.class));
+  }
+
+  @Test
+  public void acquireLatestImage_invokesMethodOnWrappedReader() {
+    imageReaderProxy.acquireLatestImage();
+
+    verify(imageReader, times(1)).acquireLatestImage();
+  }
+
+  @Test
+  public void acquireNextImage_invokesMethodOnWrappedReader() {
+    imageReaderProxy.acquireNextImage();
+
+    verify(imageReader, times(1)).acquireNextImage();
+  }
+
+  @Test
+  public void close_invokesMethodOnWrappedReader() {
+    imageReaderProxy.close();
+
+    verify(imageReader, times(1)).close();
+  }
+
+  @Test
+  public void getWidth_returnsWidthOfWrappedReader() {
+    when(imageReader.getWidth()).thenReturn(640);
+
+    assertThat(imageReaderProxy.getWidth()).isEqualTo(640);
+  }
+
+  @Test
+  public void getHeight_returnsHeightOfWrappedReader() {
+    when(imageReader.getHeight()).thenReturn(480);
+
+    assertThat(imageReaderProxy.getHeight()).isEqualTo(480);
+  }
+
+  @Test
+  public void getImageFormat_returnsImageFormatOfWrappedReader() {
+    when(imageReader.getImageFormat()).thenReturn(ImageFormat.YUV_420_888);
+
+    assertThat(imageReaderProxy.getImageFormat()).isEqualTo(ImageFormat.YUV_420_888);
+  }
+
+  @Test
+  public void getMaxImages_returnsMaxImagesOfWrappedReader() {
+    when(imageReader.getMaxImages()).thenReturn(8);
+
+    assertThat(imageReaderProxy.getMaxImages()).isEqualTo(8);
+  }
+
+  @Test
+  public void getSurface_returnsSurfaceOfWrappedReader() {
+    Surface surface = mock(Surface.class);
+    when(imageReader.getSurface()).thenReturn(surface);
+
+    assertThat(imageReaderProxy.getSurface()).isSameAs(surface);
+  }
+
+  @Test
+  public void setOnImageAvailableListener_setsListenerOfWrappedReader() {
+    ImageReaderProxy.OnImageAvailableListener listener =
+        mock(ImageReaderProxy.OnImageAvailableListener.class);
+
+    imageReaderProxy.setOnImageAvailableListener(listener, /*handler=*/ null);
+
+    ArgumentCaptor<ImageReader.OnImageAvailableListener> transformedListenerCaptor =
+        ArgumentCaptor.forClass(ImageReader.OnImageAvailableListener.class);
+    ArgumentCaptor<Handler> handlerCaptor = ArgumentCaptor.forClass(Handler.class);
+    verify(imageReader, times(1))
+        .setOnImageAvailableListener(transformedListenerCaptor.capture(), handlerCaptor.capture());
+
+    transformedListenerCaptor.getValue().onImageAvailable(imageReader);
+    verify(listener, times(1)).onImageAvailable(imageReaderProxy);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/BaseUseCaseAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/BaseUseCaseAndroidTest.java
new file mode 100644
index 0000000..b0e3f24
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/BaseUseCaseAndroidTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.util.Size;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public class BaseUseCaseAndroidTest {
+  static class TestUseCase extends FakeUseCase {
+    TestUseCase(FakeUseCaseConfiguration configuration) {
+      super(configuration);
+    }
+
+    void activate() {
+      notifyActive();
+    }
+
+    void deactivate() {
+      notifyInactive();
+    }
+
+    void update() {
+      notifyUpdated();
+    }
+
+    @Override
+    protected void updateUseCaseConfiguration(UseCaseConfiguration<?> useCaseConfiguration) {
+      super.updateUseCaseConfiguration(useCaseConfiguration);
+    }
+
+    @Override
+    protected Map<String, Size> onSuggestedResolutionUpdated(
+        Map<String, Size> suggestedResolutionMap) {
+      return suggestedResolutionMap;
+    }
+  }
+
+  private BaseUseCase.StateChangeListener mockUseCaseListener;
+
+  @Before
+  public void setup() {
+    mockUseCaseListener = Mockito.mock(BaseUseCase.StateChangeListener.class);
+  }
+
+  @Test
+  public void getAttachedCamera() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    SessionConfiguration sessionToAttach = new SessionConfiguration.Builder().build();
+    testUseCase.attachToCamera("Camera", sessionToAttach);
+
+    Set<String> attachedCameras = testUseCase.getAttachedCameraIds();
+
+    assertThat(attachedCameras).contains("Camera");
+  }
+
+  @Test
+  public void getAttachedSessionConfiguration() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    SessionConfiguration sessionToAttach = new SessionConfiguration.Builder().build();
+    testUseCase.attachToCamera("Camera", sessionToAttach);
+
+    SessionConfiguration attachedSession = testUseCase.getSessionConfiguration("Camera");
+
+    assertThat(attachedSession).isEqualTo(sessionToAttach);
+  }
+
+  @Test
+  public void removeListener() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+    testUseCase.removeStateChangeListener(mockUseCaseListener);
+
+    testUseCase.activate();
+
+    verify(mockUseCaseListener, never()).onUseCaseActive(any());
+  }
+
+  @Test
+  public void clearListeners() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+    testUseCase.clear();
+
+    testUseCase.activate();
+    verify(mockUseCaseListener, never()).onUseCaseActive(any());
+  }
+
+  @Test
+  public void notifyActiveState() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+
+    testUseCase.activate();
+    verify(mockUseCaseListener, times(1)).onUseCaseActive(testUseCase);
+  }
+
+  @Test
+  public void notifyInactiveState() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+
+    testUseCase.deactivate();
+    verify(mockUseCaseListener, times(1)).onUseCaseInactive(testUseCase);
+  }
+
+  @Test
+  public void notifyUpdatedSettings() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+
+    testUseCase.update();
+    verify(mockUseCaseListener, times(1)).onUseCaseUpdated(testUseCase);
+  }
+
+  @Test
+  public void notifyResetUseCase() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+
+    testUseCase.notifyReset();
+    verify(mockUseCaseListener, times(1)).onUseCaseReset(testUseCase);
+  }
+
+  @Test
+  public void notifySingleCapture() {
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("UseCase").build();
+    TestUseCase testUseCase = new TestUseCase(configuration);
+    testUseCase.addStateChangeListener(mockUseCaseListener);
+    CaptureRequestConfiguration captureRequestConfiguration =
+        new CaptureRequestConfiguration.Builder().build();
+
+    testUseCase.notifySingleCapture(captureRequestConfiguration);
+    verify(mockUseCaseListener, times(1))
+        .onUseCaseSingleRequest(testUseCase, captureRequestConfiguration);
+  }
+
+  @Test
+  public void useCaseConfiguration_canBeUpdated() {
+    String originalName = "UseCase";
+    FakeUseCaseConfiguration.Builder configurationBuilder =
+        new FakeUseCaseConfiguration.Builder().setTargetName(originalName);
+
+    TestUseCase testUseCase = new TestUseCase(configurationBuilder.build());
+    String originalRetrievedName = testUseCase.getUseCaseConfiguration().getTargetName();
+
+    // NOTE: Updating the use case name is probably a very bad idea in most cases. However, we'll do
+    // it here for the sake of this test.
+    String newName = "UseCase-New";
+    configurationBuilder.setTargetName(newName);
+    testUseCase.updateUseCaseConfiguration(configurationBuilder.build());
+    String newRetrievedName = testUseCase.getUseCaseConfiguration().getTargetName();
+
+    assertThat(originalRetrievedName).isEqualTo(originalName);
+    assertThat(newRetrievedName).isEqualTo(newName);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureCallbacksAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureCallbacksAndroidTest.java
new file mode 100644
index 0000000..128a51d
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureCallbacksAndroidTest.java
@@ -0,0 +1,47 @@
+/*
+ * 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 androidx.camera.core;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+@RunWith(JUnit4.class)
+public final class CameraCaptureCallbacksAndroidTest {
+
+  @Test
+  public void comboCallbackInvokesConstituentCallbacks() {
+    CameraCaptureCallback callback0 = Mockito.mock(CameraCaptureCallback.class);
+    CameraCaptureCallback callback1 = Mockito.mock(CameraCaptureCallback.class);
+    CameraCaptureCallback comboCallback =
+        CameraCaptureCallbacks.createComboCallback(callback0, callback1);
+    CameraCaptureResult result = Mockito.mock(CameraCaptureResult.class);
+    CameraCaptureFailure failure = new CameraCaptureFailure(CameraCaptureFailure.Reason.ERROR);
+
+    comboCallback.onCaptureCompleted(result);
+    verify(callback0, times(1)).onCaptureCompleted(result);
+    verify(callback1, times(1)).onCaptureCompleted(result);
+
+    comboCallback.onCaptureFailed(failure);
+    verify(callback0, times(1)).onCaptureFailed(failure);
+    verify(callback1, times(1)).onCaptureFailed(failure);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureFailureAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureFailureAndroidTest.java
new file mode 100644
index 0000000..d456c68
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureFailureAndroidTest.java
@@ -0,0 +1,34 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.camera.core.CameraCaptureFailure.Reason;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CameraCaptureFailureAndroidTest {
+
+  @Test
+  public void getReason() {
+    CameraCaptureFailure failure = new CameraCaptureFailure(Reason.ERROR);
+    assertThat(failure.getReason()).isEqualTo(Reason.ERROR);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureSessionStateCallbacksAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureSessionStateCallbacksAndroidTest.java
new file mode 100644
index 0000000..0b18d98
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraCaptureSessionStateCallbacksAndroidTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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 androidx.camera.core;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.hardware.camera2.CameraCaptureSession;
+import android.os.Build;
+import android.view.Surface;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+@RunWith(JUnit4.class)
+public final class CameraCaptureSessionStateCallbacksAndroidTest {
+
+  @Test
+  public void comboCallbackInvokesConstituentCallbacks() {
+    CameraCaptureSession.StateCallback callback0 =
+        Mockito.mock(CameraCaptureSession.StateCallback.class);
+    CameraCaptureSession.StateCallback callback1 =
+        Mockito.mock(CameraCaptureSession.StateCallback.class);
+    CameraCaptureSession.StateCallback comboCallback =
+        CameraCaptureSessionStateCallbacks.createComboCallback(callback0, callback1);
+    CameraCaptureSession session = Mockito.mock(CameraCaptureSession.class);
+    Surface surface = Mockito.mock(Surface.class);
+
+    comboCallback.onConfigured(session);
+    verify(callback0, times(1)).onConfigured(session);
+    verify(callback1, times(1)).onConfigured(session);
+
+    comboCallback.onActive(session);
+    verify(callback0, times(1)).onActive(session);
+    verify(callback1, times(1)).onActive(session);
+
+    comboCallback.onClosed(session);
+    verify(callback0, times(1)).onClosed(session);
+    verify(callback1, times(1)).onClosed(session);
+
+    comboCallback.onReady(session);
+    verify(callback0, times(1)).onReady(session);
+    verify(callback1, times(1)).onReady(session);
+
+    if (Build.VERSION.SDK_INT >= 26) {
+      comboCallback.onCaptureQueueEmpty(session);
+      verify(callback0, times(1)).onCaptureQueueEmpty(session);
+      verify(callback1, times(1)).onCaptureQueueEmpty(session);
+    }
+
+    if (Build.VERSION.SDK_INT >= 23) {
+      comboCallback.onSurfacePrepared(session, surface);
+      verify(callback0, times(1)).onSurfacePrepared(session, surface);
+      verify(callback1, times(1)).onSurfacePrepared(session, surface);
+    }
+
+    comboCallback.onConfigureFailed(session);
+    verify(callback0, times(1)).onConfigureFailed(session);
+    verify(callback1, times(1)).onConfigureFailed(session);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraDeviceStateCallbacksAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraDeviceStateCallbacksAndroidTest.java
new file mode 100644
index 0000000..b26f7c6
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraDeviceStateCallbacksAndroidTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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 androidx.camera.core;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.hardware.camera2.CameraDevice;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+@RunWith(JUnit4.class)
+public final class CameraDeviceStateCallbacksAndroidTest {
+
+  @Test
+  public void comboCallbackInvokesConstituentCallbacks() {
+    CameraDevice.StateCallback callback0 = Mockito.mock(CameraDevice.StateCallback.class);
+    CameraDevice.StateCallback callback1 = Mockito.mock(CameraDevice.StateCallback.class);
+    CameraDevice.StateCallback comboCallback =
+        CameraDeviceStateCallbacks.createComboCallback(callback0, callback1);
+    CameraDevice device = Mockito.mock(CameraDevice.class);
+
+    comboCallback.onOpened(device);
+    verify(callback0, times(1)).onOpened(device);
+    verify(callback1, times(1)).onOpened(device);
+
+    comboCallback.onClosed(device);
+    verify(callback0, times(1)).onClosed(device);
+    verify(callback1, times(1)).onClosed(device);
+
+    comboCallback.onDisconnected(device);
+    verify(callback0, times(1)).onDisconnected(device);
+    verify(callback1, times(1)).onDisconnected(device);
+
+    final int error = 1;
+    comboCallback.onError(device, error);
+    verify(callback0, times(1)).onError(device, error);
+    verify(callback1, times(1)).onError(device, error);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraRepositoryAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraRepositoryAndroidTest.java
new file mode 100644
index 0000000..58aa22f
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraRepositoryAndroidTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import androidx.camera.testing.fakes.FakeCameraFactory;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class CameraRepositoryAndroidTest {
+
+  private CameraRepository cameraRepository;
+
+  @Before
+  public void setUp() {
+    cameraRepository = new CameraRepository();
+    cameraRepository.init(new FakeCameraFactory());
+  }
+
+  @Test
+  public void cameraIdsCanBeAcquired() {
+    Set<String> cameraIds = cameraRepository.getCameraIds();
+
+    assertThat(cameraIds).isNotEmpty();
+  }
+
+  @Test
+  public void cameraCanBeObtainedWithValidId() {
+    for (String cameraId : cameraRepository.getCameraIds()) {
+      BaseCamera camera = cameraRepository.getCamera(cameraId);
+
+      assertThat(camera).isNotNull();
+    }
+  }
+
+  @Test
+  public void cameraCannotBeObtainedWithInvalidId() {
+    assertThrows(IllegalArgumentException.class, () -> cameraRepository.getCamera("no_such_id"));
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraUtil.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraUtil.java
new file mode 100644
index 0000000..b101ca6e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraUtil.java
@@ -0,0 +1,145 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraDevice.StateCallback;
+import android.hardware.camera2.CameraManager;
+import android.os.Handler;
+import android.os.HandlerThread;
+import androidx.test.core.app.ApplicationProvider;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Utility functions for obtaining instances of camera2 classes. */
+public final class CameraUtil {
+  /** Amount of time to wait before timing out when trying to open a {@link CameraDevice}. */
+  private static final int CAMERA_OPEN_TIMEOUT_SECONDS = 2;
+
+  /**
+   * Gets a new instance of a {@link CameraDevice}.
+   *
+   * <p>This method attempts to open up a new camera. Since the camera api is asynchronous it needs
+   * to wait for camera open
+   *
+   * <p>After the camera is no longer needed {@link #releaseCameraDevice(CameraDevice)} should be
+   * called to clean up resources.
+   *
+   * @throws CameraAccessException if the device is unable to access the camera
+   * @throws InterruptedException if a {@link CameraDevice} can not be retrieved within a set time
+   */
+  public static CameraDevice getCameraDevice() throws CameraAccessException, InterruptedException {
+    // Setup threading required for callback on openCamera()
+    HandlerThread handlerThread = new HandlerThread("handler thread");
+    handlerThread.start();
+    Handler handler = new Handler(handlerThread.getLooper());
+
+    CameraManager cameraManager = getCameraManager();
+
+    // Use the first camera available.
+    String[] cameraIds = cameraManager.getCameraIdList();
+    if (cameraIds.length <= 0) {
+      throw new CameraAccessException(
+          CameraAccessException.CAMERA_ERROR, "Device contains no cameras.");
+    }
+    String cameraName = cameraIds[0];
+
+    // Use an AtomicReference to store the CameraDevice because it is initialized in a lambda. This
+    // way the AtomicReference itself is effectively final.
+    AtomicReference<CameraDevice> cameraDeviceHolder = new AtomicReference<>();
+
+    // Open the camera using the CameraManager which returns a valid and open CameraDevice only when
+    // onOpened() is called.
+    CountDownLatch latch = new CountDownLatch(1);
+    cameraManager.openCamera(
+        cameraName,
+        new StateCallback() {
+          @Override
+          public void onOpened(CameraDevice camera) {
+            cameraDeviceHolder.set(camera);
+            latch.countDown();
+          }
+
+          @Override
+          public void onClosed(CameraDevice cameraDevice) {
+            handlerThread.quit();
+          }
+
+          @Override
+          public void onDisconnected(CameraDevice camera) {}
+
+          @Override
+          public void onError(CameraDevice camera, int error) {}
+        },
+        handler);
+
+    // Wait for the callback to initialize the CameraDevice
+    latch.await(CAMERA_OPEN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+    return cameraDeviceHolder.get();
+  }
+
+  /**
+   * Cleans up resources that need to be kept around while the camera device is active.
+   *
+   * @param cameraDevice camera that was obtained via {@link #getCameraDevice()}
+   */
+  public static void releaseCameraDevice(CameraDevice cameraDevice) {
+    cameraDevice.close();
+  }
+
+  public static CameraManager getCameraManager() {
+    return (CameraManager)
+        ApplicationProvider.getApplicationContext().getSystemService(Context.CAMERA_SERVICE);
+  }
+
+  /**
+   * Opens a camera and associates the camera with multiple use cases.
+   *
+   * <p>Sets the use case to be online and active, so that the use case is in a state to issue
+   * capture requests to the camera. The caller is responsible for making the use case inactive and
+   * offline and for closing the camera afterwards.
+   *
+   * @param camera to open
+   * @param useCases to associate with
+   */
+  public static void openCameraWithUseCase(BaseCamera camera, BaseUseCase ...useCases) {
+    camera.addOnlineUseCase(Arrays.asList(useCases));
+    for (BaseUseCase useCase : useCases) {
+      camera.onUseCaseActive(useCase);
+    }
+  }
+
+  /**
+   * Detach multiple use cases from a camera.
+   *
+   * <p>Sets the use cases to be inactive and remove from the online list.
+   *
+   * @param camera to detach from
+   * @param useCases to be detached
+   */
+  public static void detachUseCaseFromCamera(BaseCamera camera, BaseUseCase ...useCases) {
+    for (BaseUseCase useCase : useCases) {
+      camera.onUseCaseInactive(useCase);
+    }
+    camera.removeOnlineUseCase(Arrays.asList(useCases));
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CameraXAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CameraXAndroidTest.java
new file mode 100644
index 0000000..0d4b9a4
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CameraXAndroidTest.java
@@ -0,0 +1,266 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.spy;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Size;
+import androidx.camera.core.CameraX.ErrorCode;
+import androidx.camera.core.CameraX.ErrorListener;
+import androidx.camera.core.CameraX.LensFacing;
+import androidx.camera.testing.fakes.FakeCameraDeviceSurfaceManager;
+import androidx.camera.testing.fakes.FakeCameraFactory;
+import androidx.camera.testing.fakes.FakeDefaultUseCaseConfigFactory;
+import androidx.camera.testing.fakes.FakeLifecycleOwner;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public final class CameraXAndroidTest {
+  private FakeLifecycleOwner lifecycle;
+  String cameraId;
+  BaseCamera camera;
+  static CameraFactory cameraFactory = new FakeCameraFactory();
+
+  private static class CountingErrorListener implements ErrorListener {
+    CountDownLatch latch;
+    AtomicInteger count = new AtomicInteger(0);
+
+    CountingErrorListener(CountDownLatch latch) {
+      this.latch = latch;
+    }
+
+    @Override
+    public void onError(ErrorCode errorCode, String message) {
+      count.getAndIncrement();
+      latch.countDown();
+    }
+
+    public int getCount() {
+      return count.get();
+    }
+  }
+
+  private CountingErrorListener errorListener;
+  private CountDownLatch latch;
+  private HandlerThread handlerThread;
+  private Handler handler;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    CameraDeviceSurfaceManager surfaceManager = new FakeCameraDeviceSurfaceManager();
+    UseCaseConfigurationFactory defaultConfigFactory = new FakeDefaultUseCaseConfigFactory();
+    AppConfiguration.Builder appConfigBuilder =
+        new AppConfiguration.Builder()
+            .setCameraFactory(cameraFactory)
+            .setDeviceSurfaceManager(surfaceManager)
+            .setUseCaseConfigFactory(defaultConfigFactory);
+
+    // CameraX.init will actually init just once across all test cases. However we need to get
+    // the real CameraFactory instance being injected into the init process.  So here we store the
+    // CameraFactory instance in static fields.
+    CameraX.init(context, appConfigBuilder.build());
+    lifecycle = new FakeLifecycleOwner();
+    cameraId = getCameraIdUnchecked(LensFacing.BACK);
+    camera = cameraFactory.getCamera(cameraId);
+    latch = new CountDownLatch(1);
+    errorListener = new CountingErrorListener(latch);
+    handlerThread = new HandlerThread("ErrorHandlerThread");
+    handlerThread.start();
+    handler = new Handler(handlerThread.getLooper());
+  }
+
+  @After
+  public void tearDown() throws InterruptedException {
+    CameraX.unbindAll();
+    handlerThread.quitSafely();
+
+    // Wait some time for the cameras to close. We need the cameras to close to bring CameraX back
+    // to the initial state.
+    Thread.sleep(3000);
+  }
+
+  @Test
+  public void bind_createsNewUseCaseGroup() {
+    CameraX.bindToLifecycle(lifecycle, new FakeUseCase());
+
+    // One observer is the use case group. The other observer removes the use case upon the
+    // lifecycle's destruction.
+    assertThat(lifecycle.getObserverCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void bindMultipleUseCases() {
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config0").build();
+    FakeUseCase fakeUseCase = new FakeUseCase(configuration0);
+    FakeOtherUseCaseConfiguration configuration1 =
+        new FakeOtherUseCaseConfiguration.Builder().setTargetName("config1").build();
+    FakeOtherUseCase fakeOtherUseCase = new FakeOtherUseCase(configuration1);
+
+    CameraX.bindToLifecycle(lifecycle, fakeUseCase, fakeOtherUseCase);
+
+    assertThat(CameraX.isBound(fakeUseCase)).isTrue();
+    assertThat(CameraX.isBound(fakeOtherUseCase)).isTrue();
+  }
+
+  @Test
+  public void isNotBound_afterUnbind() {
+    FakeUseCase fakeUseCase = new FakeUseCase();
+    CameraX.bindToLifecycle(lifecycle, fakeUseCase);
+
+    CameraX.unbind(fakeUseCase);
+    assertThat(CameraX.isBound(fakeUseCase)).isFalse();
+  }
+
+  @Test
+  public void bind_createsDifferentUseCaseGroups_forDifferentLifecycles() {
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config0").build();
+    CameraX.bindToLifecycle(lifecycle, new FakeUseCase(configuration0));
+
+    FakeUseCaseConfiguration configuration1 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config1").build();
+    FakeLifecycleOwner anotherLifecycle = new FakeLifecycleOwner();
+    CameraX.bindToLifecycle(anotherLifecycle, new FakeUseCase(configuration1));
+
+    // One observer is the use case group. The other observer removes the use case upon the
+    // lifecycle's destruction.
+    assertThat(lifecycle.getObserverCount()).isEqualTo(2);
+    assertThat(anotherLifecycle.getObserverCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void exception_withDestroyedLifecycle() {
+    FakeUseCase useCase = new FakeUseCase();
+
+    lifecycle.destroy();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          CameraX.bindToLifecycle(lifecycle, useCase);
+        });
+  }
+
+  @Test
+  public void errorListenerGetsCalled_whenErrorPosted() throws InterruptedException {
+    CameraX.setErrorListener(errorListener, handler);
+    CameraX.postError(CameraX.ErrorCode.CAMERA_STATE_INCONSISTENT, "");
+    latch.await(1, TimeUnit.SECONDS);
+
+    assertThat(errorListener.getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void requestingDefaultConfiguration_returnsDefaultConfiguration() {
+    // Requesting a default configuration will throw if CameraX is not initialized.
+    FakeUseCaseConfiguration config =
+        CameraX.getDefaultUseCaseConfiguration(FakeUseCaseConfiguration.class);
+    assertThat(config).isNotNull();
+    assertThat(config.getTargetClass(null)).isEqualTo(FakeUseCase.class);
+  }
+
+  @Test
+  public void attachCameraControl_afterBindToLifecycle() {
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config0").build();
+    AttachCameraFakeCase fakeUseCase = new AttachCameraFakeCase(configuration0);
+
+    CameraX.bindToLifecycle(lifecycle, fakeUseCase);
+
+    assertThat(fakeUseCase.getCameraControl(cameraId)).isEqualTo(camera.getCameraControl());
+  }
+
+  @Test
+  public void onCameraControlReadyIsCalled_afterBindToLifecycle() {
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config0").build();
+    AttachCameraFakeCase fakeUseCase = spy(new AttachCameraFakeCase(configuration0));
+
+    CameraX.bindToLifecycle(lifecycle, fakeUseCase);
+
+    Mockito.verify(fakeUseCase).onCameraControlReady(cameraId);
+  }
+
+  @Test
+  public void detachCameraControl_afterUnbind() {
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder().setTargetName("config0").build();
+    AttachCameraFakeCase fakeUseCase = new AttachCameraFakeCase(configuration0);
+    CameraX.bindToLifecycle(lifecycle, fakeUseCase);
+
+    CameraX.unbind(fakeUseCase);
+
+    // after unbind, Camera's CameraControl should be detached from Usecase
+    assertThat(fakeUseCase.getCameraControl(cameraId)).isNotEqualTo(camera.getCameraControl());
+    // UseCase still gets a non-null default CameraControl that does nothing.
+    assertThat(fakeUseCase.getCameraControl(cameraId)).isNotNull();
+  }
+
+  @Test
+  public void canRetrieveCameraInfo() throws CameraInfoUnavailableException {
+    String cameraId = CameraX.getCameraWithLensFacing(LensFacing.BACK);
+    CameraInfo cameraInfo = CameraX.getCameraInfo(cameraId);
+    assertThat(cameraInfo).isNotNull();
+  }
+
+  /** FakeUseCase that will call attachToCamera */
+  public static class AttachCameraFakeCase extends FakeUseCase {
+
+    public AttachCameraFakeCase(FakeUseCaseConfiguration configuration) {
+      super(configuration);
+    }
+
+    @Override
+    protected Map<String, Size> onSuggestedResolutionUpdated(
+        Map<String, Size> suggestedResolutionMap) {
+
+      SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+      CameraDeviceConfiguration configuration =
+          (CameraDeviceConfiguration) getUseCaseConfiguration();
+      String cameraId = getCameraIdUnchecked(configuration.getLensFacing());
+      attachToCamera(cameraId, builder.build());
+      return suggestedResolutionMap;
+    }
+  }
+
+  private static final String getCameraIdUnchecked(LensFacing lensFacing) {
+    try {
+      return CameraX.getCameraWithLensFacing(lensFacing);
+    } catch (Exception e) {
+      throw new IllegalArgumentException(
+          "Unable to get camera id for camera lens facing " + lensFacing, e);
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestConfigurationAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestConfigurationAndroidTest.java
new file mode 100644
index 0000000..b80648b
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestConfigurationAndroidTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.CaptureRequest.Key;
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public class CaptureRequestConfigurationAndroidTest {
+  private DeferrableSurface mockSurface0;
+
+  @Before
+  public void setup() {
+    mockSurface0 = Mockito.mock(DeferrableSurface.class);
+  }
+
+  @Test
+  public void buildCaptureRequestWithNullCameraDevice() throws CameraAccessException {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+    CameraDevice cameraDevice = null;
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    CaptureRequest.Builder captureRequestBuilder =
+        captureRequestConfiguration.buildCaptureRequest(cameraDevice);
+
+    assertThat(captureRequestBuilder).isNull();
+  }
+
+  @Test
+  public void builderSetTemplate() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    assertThat(captureRequestConfiguration.getTemplateType()).isEqualTo(CameraDevice.TEMPLATE_PREVIEW);
+  }
+
+  @Test
+  public void builderAddSurface() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    List<DeferrableSurface> surfaces = captureRequestConfiguration.getSurfaces();
+
+    assertThat(surfaces).hasSize(1);
+    assertThat(surfaces).contains(mockSurface0);
+  }
+
+  @Test
+  public void builderRemoveSurface() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    builder.removeSurface(mockSurface0);
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    List<Surface> surfaces =
+        DeferrableSurfaces.surfaceList(captureRequestConfiguration.getSurfaces());
+    assertThat(surfaces).isEmpty();
+  }
+
+  @Test
+  public void builderClearSurface() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    builder.clearSurfaces();
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    List<Surface> surfaces =
+        DeferrableSurfaces.surfaceList(captureRequestConfiguration.getSurfaces());
+    assertThat(surfaces.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void builderAddCharacteristic() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    Map<Key<?>, CaptureRequestParameter<?>> parameterMap =
+        captureRequestConfiguration.getCameraCharacteristics();
+
+    assertThat(parameterMap.containsKey(CaptureRequest.CONTROL_AF_MODE)).isTrue();
+    assertThat(parameterMap)
+        .containsEntry(
+            CaptureRequest.CONTROL_AF_MODE,
+            CaptureRequestParameter.create(
+                CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO));
+  }
+
+  @Test
+  public void builderSetUseTargetedSurface() {
+    CaptureRequestConfiguration.Builder builder = new CaptureRequestConfiguration.Builder();
+
+    builder.setUseRepeatingSurface(true);
+    CaptureRequestConfiguration captureRequestConfiguration = builder.build();
+
+    assertThat(captureRequestConfiguration.isUseRepeatingSurface()).isTrue();
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestParameterAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestParameterAndroidTest.java
new file mode 100644
index 0000000..ea0bd15
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/CaptureRequestParameterAndroidTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CaptureRequest;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class CaptureRequestParameterAndroidTest {
+  private CameraDevice cameraDevice;
+
+  @Before
+  public void setup() throws CameraAccessException, InterruptedException {
+    cameraDevice = CameraUtil.getCameraDevice();
+  }
+
+  @After
+  public void teardown() {
+    CameraUtil.releaseCameraDevice(cameraDevice);
+  }
+
+  @Test
+  public void instanceCreation() {
+    CaptureRequestParameter<?> captureRequestParameter =
+        CaptureRequestParameter.create(
+            CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    assertThat(captureRequestParameter.getKey()).isEqualTo(CaptureRequest.CONTROL_AF_MODE);
+    assertThat(captureRequestParameter.getValue()).isEqualTo(CaptureRequest.CONTROL_AF_MODE_AUTO);
+  }
+
+  @Test
+  public void applyParameter() throws CameraAccessException {
+    CaptureRequest.Builder builder =
+        cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
+
+    assertThat(builder).isNotNull();
+
+    CaptureRequestParameter<?> captureRequestParameter =
+        CaptureRequestParameter.create(
+            CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    captureRequestParameter.apply(builder);
+
+    assertThat(builder.get(CaptureRequest.CONTROL_AF_MODE))
+        .isEqualTo(CaptureRequest.CONTROL_AF_MODE_AUTO);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ErrorHandlerAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ErrorHandlerAndroidTest.java
new file mode 100644
index 0000000..a0adb4e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ErrorHandlerAndroidTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import androidx.camera.core.CameraX.ErrorCode;
+import androidx.camera.core.CameraX.ErrorListener;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ErrorHandlerAndroidTest {
+  private ErrorHandler errorHandler;
+
+  private static class CountingErrorListener implements ErrorListener {
+    CountDownLatch latch;
+    AtomicInteger count = new AtomicInteger(0);
+
+    CountingErrorListener(CountDownLatch latch) {
+      this.latch = latch;
+    }
+
+    @Override
+    public void onError(ErrorCode errorCode, String message) {
+      count.getAndIncrement();
+      latch.countDown();
+    }
+
+    public int getCount() {
+      return count.get();
+    }
+  }
+
+  private CountingErrorListener errorListener0;
+  private CountingErrorListener errorListener1;
+
+  private HandlerThread handlerThread;
+  private Handler handler;
+
+  private CountDownLatch latch;
+
+  @Before
+  public void setup() {
+    errorHandler = new ErrorHandler();
+    latch = new CountDownLatch(1);
+    errorListener0 = new CountingErrorListener(latch);
+    errorListener1 = new CountingErrorListener(latch);
+
+    handlerThread = new HandlerThread("ErrorHandlerThread");
+    handlerThread.start();
+    handler = new Handler(handlerThread.getLooper());
+  }
+
+  @Test
+  public void errorListenerCalled_whenSet() throws InterruptedException {
+    errorHandler.setErrorListener(errorListener0, handler);
+
+    errorHandler.postError(CameraX.ErrorCode.CAMERA_STATE_INCONSISTENT, "");
+
+    latch.await(1, TimeUnit.SECONDS);
+
+    assertThat(errorListener0.getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void errorListenerRemoved_whenNullSet() throws InterruptedException {
+    errorHandler.setErrorListener(errorListener0, handler);
+    errorHandler.setErrorListener(null, handler);
+
+    errorHandler.postError(CameraX.ErrorCode.CAMERA_STATE_INCONSISTENT, "");
+
+    assertThat(latch.await(1, TimeUnit.SECONDS)).isFalse();
+  }
+
+  @Test
+  public void errorListenerReplaced() throws InterruptedException {
+    errorHandler.setErrorListener(errorListener0, handler);
+    errorHandler.setErrorListener(errorListener1, handler);
+
+    errorHandler.postError(CameraX.ErrorCode.CAMERA_STATE_INCONSISTENT, "");
+
+    latch.await(1, TimeUnit.SECONDS);
+
+    assertThat(errorListener0.getCount()).isEqualTo(0);
+    assertThat(errorListener1.getCount()).isEqualTo(1);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/FakeActivity.java b/camera/core/src/androidTest/java/androidx/camera/core/FakeActivity.java
new file mode 100644
index 0000000..54658e1
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/FakeActivity.java
@@ -0,0 +1,36 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/** A fake {@link Activity} that checks properties of the CameraX library. */
+public class FakeActivity extends Activity {
+  private volatile boolean isCameraXInitializedAtOnCreate = false;
+
+  @Override
+  protected void onCreate(Bundle savedInstance) {
+    super.onCreate(savedInstance);
+    isCameraXInitializedAtOnCreate = CameraX.isInitialized();
+  }
+
+  /** Returns true if CameraX is initialized when {@link #onCreate(Bundle)} is called. */
+  public boolean isCameraXInitializedAtOnCreate() {
+    return isCameraXInitializedAtOnCreate;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCase.java b/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCase.java
new file mode 100644
index 0000000..98d4afc
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCase.java
@@ -0,0 +1,63 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.util.Size;
+import androidx.camera.core.CameraX.LensFacing;
+import java.util.Map;
+
+/**
+ * A second fake {@link BaseUseCase}.
+ *
+ * <p>This is used to complement the {@link FakeUseCase} for testing instances where a use case of
+ * different type is created.
+ */
+class FakeOtherUseCase extends BaseUseCase {
+  private volatile boolean isCleared = false;
+
+  /** Creates a new instance of a {@link FakeOtherUseCase} with a given configuration. */
+  FakeOtherUseCase(FakeOtherUseCaseConfiguration configuration) {
+    super(configuration);
+  }
+
+  /** Creates a new instance of a {@link FakeOtherUseCase} with a default configuration. */
+  FakeOtherUseCase() {
+    this(new FakeOtherUseCaseConfiguration.Builder().build());
+  }
+
+  @Override
+  public void clear() {
+    super.clear();
+    isCleared = true;
+  }
+
+  @Override
+  protected UseCaseConfiguration.Builder<?, ?, ?> getDefaultBuilder() {
+    return new FakeOtherUseCaseConfiguration.Builder().setLensFacing(LensFacing.BACK);
+  }
+
+  @Override
+  protected Map<String, Size> onSuggestedResolutionUpdated(
+      Map<String, Size> suggestedResolutionMap) {
+    return suggestedResolutionMap;
+  }
+
+  /** Returns true if {@link #clear()} has been called previously. */
+  public boolean isCleared() {
+    return isCleared;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCaseConfiguration.java b/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCaseConfiguration.java
new file mode 100644
index 0000000..83624df
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/FakeOtherUseCaseConfiguration.java
@@ -0,0 +1,62 @@
+/*
+ * 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 androidx.camera.core;
+
+/** A fake configuration for {@link FakeOtherUseCase}. */
+public class FakeOtherUseCaseConfiguration
+    implements UseCaseConfiguration<FakeOtherUseCase>, CameraDeviceConfiguration {
+
+  private final Configuration config;
+
+  private FakeOtherUseCaseConfiguration(Configuration config) {
+    this.config = config;
+  }
+
+  @Override
+  public Configuration getConfiguration() {
+    return config;
+  }
+
+  /** Builder for an empty Configuration */
+  public static final class Builder
+      implements UseCaseConfiguration.Builder<
+              FakeOtherUseCase, FakeOtherUseCaseConfiguration, Builder>,
+          CameraDeviceConfiguration.Builder<FakeOtherUseCaseConfiguration, Builder> {
+
+    private final MutableOptionsBundle optionsBundle;
+
+    public Builder() {
+      optionsBundle = MutableOptionsBundle.create();
+      setTargetClass(FakeOtherUseCase.class);
+    }
+
+    @Override
+    public MutableConfiguration getMutableConfiguration() {
+      return optionsBundle;
+    }
+
+    @Override
+    public Builder builder() {
+      return this;
+    }
+
+    @Override
+    public FakeOtherUseCaseConfiguration build() {
+      return new FakeOtherUseCaseConfiguration(OptionsBundle.from(optionsBundle));
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCase.java b/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCase.java
new file mode 100644
index 0000000..2e0dd65
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCase.java
@@ -0,0 +1,63 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.support.annotation.RestrictTo;
+import android.support.annotation.RestrictTo.Scope;
+import android.util.Size;
+import androidx.camera.core.CameraX.LensFacing;
+import java.util.Map;
+
+/** A fake {@link BaseUseCase}. */
+@RestrictTo(Scope.LIBRARY_GROUP)
+public class FakeUseCase extends BaseUseCase {
+  private volatile boolean isCleared = false;
+
+  /** Creates a new instance of a {@link FakeUseCase} with a given configuration. */
+  protected FakeUseCase(FakeUseCaseConfiguration configuration) {
+    super(configuration);
+  }
+
+  /** Creates a new instance of a {@link FakeUseCase} with a default configuration. */
+  protected FakeUseCase() {
+    this(new FakeUseCaseConfiguration.Builder().build());
+  }
+
+  @Override
+  protected UseCaseConfiguration.Builder<?, ?, ?> getDefaultBuilder() {
+    return new FakeUseCaseConfiguration.Builder()
+        .setLensFacing(LensFacing.BACK)
+        .setOptionUnpacker((useCaseConfig, sessionConfigBuilder) -> {});
+  }
+
+  @Override
+  public void clear() {
+    super.clear();
+    isCleared = true;
+  }
+
+  @Override
+  protected Map<String, Size> onSuggestedResolutionUpdated(
+      Map<String, Size> suggestedResolutionMap) {
+    return suggestedResolutionMap;
+  }
+
+  /** Returns true if {@link #clear()} has been called previously. */
+  public boolean isCleared() {
+    return isCleared;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCaseConfiguration.java b/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCaseConfiguration.java
new file mode 100644
index 0000000..f8e77e8
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/FakeUseCaseConfiguration.java
@@ -0,0 +1,61 @@
+/*
+ * 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 androidx.camera.core;
+
+/** A fake configuration for {@link FakeUseCase}. */
+public class FakeUseCaseConfiguration
+    implements UseCaseConfiguration<FakeUseCase>, CameraDeviceConfiguration {
+
+  private final Configuration config;
+
+  private FakeUseCaseConfiguration(Configuration config) {
+    this.config = config;
+  }
+
+  @Override
+  public Configuration getConfiguration() {
+    return config;
+  }
+
+  /** Builder for an empty Configuration */
+  public static final class Builder
+      implements UseCaseConfiguration.Builder<FakeUseCase, FakeUseCaseConfiguration, Builder>,
+          CameraDeviceConfiguration.Builder<FakeUseCaseConfiguration, Builder> {
+
+    private final MutableOptionsBundle optionsBundle;
+
+    public Builder() {
+      optionsBundle = MutableOptionsBundle.create();
+      setTargetClass(FakeUseCase.class);
+    }
+
+    @Override
+    public MutableConfiguration getMutableConfiguration() {
+      return optionsBundle;
+    }
+
+    @Override
+    public Builder builder() {
+      return this;
+    }
+
+    @Override
+    public FakeUseCaseConfiguration build() {
+      return new FakeUseCaseConfiguration(OptionsBundle.from(optionsBundle));
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageProxyAndroidTest.java
new file mode 100644
index 0000000..27eb93c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageProxyAndroidTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.graphics.Rect;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class ForwardingImageProxyAndroidTest {
+
+  private final ImageProxy baseImageProxy = mock(ImageProxy.class);
+  private final ImageProxy.PlaneProxy yPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ImageProxy.PlaneProxy uPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ImageProxy.PlaneProxy vPlane = mock(ImageProxy.PlaneProxy.class);
+  private ForwardingImageProxy imageProxy;
+
+  @Before
+  public void setUp() {
+    imageProxy = new ConcreteImageProxy(baseImageProxy);
+  }
+
+  @Test
+  public void close_closesWrappedImage() {
+    imageProxy.close();
+
+    verify(baseImageProxy).close();
+  }
+
+  @Test(timeout = 2000)
+  public void close_notifiesOnImageCloseListener_afterSetOnImageCloseListener()
+      throws InterruptedException {
+    Semaphore closedImageSemaphore = new Semaphore(/*permits=*/ 0);
+    AtomicReference<ImageProxy> closedImage = new AtomicReference<>();
+    imageProxy.addOnImageCloseListener(
+        image -> {
+          closedImage.set(image);
+          closedImageSemaphore.release();
+        });
+
+    imageProxy.close();
+
+    closedImageSemaphore.acquire();
+    assertThat(closedImage.get()).isSameAs(imageProxy);
+  }
+
+  @Test
+  public void getCropRect_returnsCropRectForWrappedImage() {
+    when(baseImageProxy.getCropRect()).thenReturn(new Rect(0, 0, 20, 20));
+
+    assertThat(imageProxy.getCropRect()).isEqualTo(new Rect(0, 0, 20, 20));
+  }
+
+  @Test
+  public void setCropRect_setsCropRectForWrappedImage() {
+    imageProxy.setCropRect(new Rect(0, 0, 40, 40));
+
+    verify(baseImageProxy).setCropRect(new Rect(0, 0, 40, 40));
+  }
+
+  @Test
+  public void getFormat_returnsFormatForWrappedImage() {
+    when(baseImageProxy.getFormat()).thenReturn(ImageFormat.YUV_420_888);
+
+    assertThat(imageProxy.getFormat()).isEqualTo(ImageFormat.YUV_420_888);
+  }
+
+  @Test
+  public void getHeight_returnsHeightForWrappedImage() {
+    when(baseImageProxy.getHeight()).thenReturn(480);
+
+    assertThat(imageProxy.getHeight()).isEqualTo(480);
+  }
+
+  @Test
+  public void getWidth_returnsWidthForWrappedImage() {
+    when(baseImageProxy.getWidth()).thenReturn(640);
+
+    assertThat(imageProxy.getWidth()).isEqualTo(640);
+  }
+
+  @Test
+  public void getTimestamp_returnsTimestampForWrappedImage() {
+    when(baseImageProxy.getTimestamp()).thenReturn(138990020L);
+
+    assertThat(imageProxy.getTimestamp()).isEqualTo(138990020L);
+  }
+
+  @Test
+  public void setTimestamp_setsTimestampForWrappedImage() {
+    imageProxy.setTimestamp(138990020L);
+
+    verify(baseImageProxy).setTimestamp(138990020L);
+  }
+
+  @Test
+  public void getPlanes_returnsPlanesForWrappedImage() {
+    when(baseImageProxy.getPlanes())
+        .thenReturn(new ImageProxy.PlaneProxy[] {yPlane, uPlane, vPlane});
+
+    ImageProxy.PlaneProxy[] planes = imageProxy.getPlanes();
+    assertThat(planes.length).isEqualTo(3);
+    assertThat(planes[0]).isEqualTo(yPlane);
+    assertThat(planes[1]).isEqualTo(uPlane);
+    assertThat(planes[2]).isEqualTo(vPlane);
+  }
+
+  private static final class ConcreteImageProxy extends ForwardingImageProxy {
+    private ConcreteImageProxy(ImageProxy baseImageProxy) {
+      super(baseImageProxy);
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageReaderListenerAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageReaderListenerAndroidTest.java
new file mode 100644
index 0000000..d186b0b
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ForwardingImageReaderListenerAndroidTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import android.media.ImageReader;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Semaphore;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class ForwardingImageReaderListenerAndroidTest {
+  private static final int IMAGE_WIDTH = 640;
+  private static final int IMAGE_HEIGHT = 480;
+  private static final int IMAGE_FORMAT = ImageFormat.YUV_420_888;
+  private static final int MAX_IMAGES = 10;
+
+  private final ImageReader imageReader = mock(ImageReader.class);
+  private final Surface surface = mock(Surface.class);
+  private HandlerThread handlerThread;
+  private Handler handler;
+  private List<QueuedImageReaderProxy> imageReaderProxys;
+  private ForwardingImageReaderListener forwardingListener;
+
+  @Before
+  public void setUp() {
+    handlerThread = new HandlerThread("listener");
+    handlerThread.start();
+    handler = new Handler(handlerThread.getLooper());
+    imageReaderProxys = new ArrayList<>(3);
+    for (int i = 0; i < 3; ++i) {
+      imageReaderProxys.add(
+          new QueuedImageReaderProxy(IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_FORMAT, MAX_IMAGES, surface));
+    }
+    forwardingListener = new ForwardingImageReaderListener(imageReaderProxys);
+  }
+
+  @After
+  public void tearDown() {
+    handlerThread.quitSafely();
+  }
+
+  @Test
+  public void newImageIsForwardedToAllListeners() {
+    Image baseImage = createMockImage();
+    when(imageReader.acquireNextImage()).thenReturn(baseImage);
+    List<ImageReaderProxy.OnImageAvailableListener> listeners = new ArrayList<>();
+    for (ImageReaderProxy imageReaderProxy : imageReaderProxys) {
+      ImageReaderProxy.OnImageAvailableListener listener = createMockListener();
+      imageReaderProxy.setOnImageAvailableListener(listener, handler);
+      listeners.add(listener);
+    }
+
+    final int availableImages = 5;
+    for (int i = 0; i < availableImages; ++i) {
+      forwardingListener.onImageAvailable(imageReader);
+    }
+
+    for (int i = 0; i < imageReaderProxys.size(); ++i) {
+      // Listener should be notified about every available image.
+      verify(listeners.get(i), timeout(2000).times(availableImages))
+          .onImageAvailable(imageReaderProxys.get(i));
+    }
+  }
+
+  @Test(timeout = 2000)
+  public void baseImageIsClosed_allQueuesAreCleared_whenAllForwardedCopiesAreClosed()
+      throws InterruptedException {
+    Semaphore  Semaphore(/*permits=*/ 0);
+    Image baseImage = createMockImage();
+    when(imageReader.acquireNextImage()).thenReturn(baseImage);
+    for (ImageReaderProxy imageReaderProxy : imageReaderProxys) {
+      // Close the image for every listener.
+      imageReaderProxy.setOnImageAvailableListener(
+          createSemaphoreReleasingClosingListener(onCloseSemaphore), handler);
+    }
+
+    final int availableImages = 5;
+    for (int i = 0; i < availableImages; ++i) {
+      forwardingListener.onImageAvailable(imageReader);
+    }
+    onCloseSemaphore.acquire(availableImages * imageReaderProxys.size());
+
+    // Base image should be closed every time.
+    verify(baseImage, times(availableImages)).close();
+    // All queues should be cleared.
+    for (QueuedImageReaderProxy imageReaderProxy : imageReaderProxys) {
+      assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(0);
+    }
+  }
+
+  @Test(timeout = 2000)
+  public void baseImageIsNotClosed_someQueuesAreCleared_whenNotAllForwardedCopiesAreClosed()
+      throws InterruptedException {
+    Semaphore  Semaphore(/*permits=*/ 0);
+    Image baseImage = createMockImage();
+    when(imageReader.acquireNextImage()).thenReturn(baseImage);
+    // Don't close the image for the first listener.
+    imageReaderProxys.get(0).setOnImageAvailableListener(createMockListener(), handler);
+    // Close the image for the other listeners.
+    imageReaderProxys
+        .get(1)
+        .setOnImageAvailableListener(
+            createSemaphoreReleasingClosingListener(onCloseSemaphore), handler);
+    imageReaderProxys
+        .get(2)
+        .setOnImageAvailableListener(
+            createSemaphoreReleasingClosingListener(onCloseSemaphore), handler);
+
+    final int availableImages = 5;
+    for (int i = 0; i < availableImages; ++i) {
+      forwardingListener.onImageAvailable(imageReader);
+    }
+    onCloseSemaphore.acquire(availableImages * (imageReaderProxys.size() - 1));
+
+    // Base image should not be closed every time.
+    verify(baseImage, never()).close();
+    // First reader's queue should not be cleared.
+    assertThat(imageReaderProxys.get(0).getCurrentImages()).isEqualTo(availableImages);
+    // Other readers' queues should be cleared.
+    assertThat(imageReaderProxys.get(1).getCurrentImages()).isEqualTo(0);
+    assertThat(imageReaderProxys.get(2).getCurrentImages()).isEqualTo(0);
+  }
+
+  private static Image createMockImage() {
+    Image image = mock(Image.class);
+    when(image.getWidth()).thenReturn(IMAGE_WIDTH);
+    when(image.getHeight()).thenReturn(IMAGE_HEIGHT);
+    when(image.getFormat()).thenReturn(IMAGE_FORMAT);
+    return image;
+  }
+
+  private static ImageReaderProxy.OnImageAvailableListener createMockListener() {
+    return mock(ImageReaderProxy.OnImageAvailableListener.class);
+  }
+
+  /**
+   * Returns a listener which immediately acquires the next image, closes the image, and releases a
+   * semaphore.
+   */
+  private static ImageReaderProxy.OnImageAvailableListener createSemaphoreReleasingClosingListener(
+      Semaphore semaphore) {
+    return imageReaderProxy -> {
+      imageReaderProxy.acquireNextImage().close();
+      semaphore.release();
+    };
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ImageProxyDownsamplerAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ImageProxyDownsamplerAndroidTest.java
new file mode 100644
index 0000000..66b6323
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ImageProxyDownsamplerAndroidTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import androidx.test.runner.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class ImageProxyDownsamplerAndroidTest {
+  private static final int WIDTH = 8;
+  private static final int HEIGHT = 8;
+
+  @Test
+  public void nearestNeighborDownsamplingBy2X_whenUVPlanesHavePixelStride1() {
+    ImageProxy inputImage = createYuv420Image(/*uvPixelStride=*/ 1);
+    int downsamplingFactor = 2;
+    ImageProxy outputImage =
+        ImageProxyDownsampler.downsample(
+            inputImage,
+            WIDTH / downsamplingFactor,
+            HEIGHT / downsamplingFactor,
+            ImageProxyDownsampler.DownsamplingMethod.NEAREST_NEIGHBOR);
+
+    checkOutputIsNearestNeighborDownsampledInput(inputImage, outputImage, downsamplingFactor);
+  }
+
+  @Test
+  public void nearestNeighborDownsamplingBy2X_whenUVPlanesHavePixelStride2() {
+    ImageProxy inputImage = createYuv420Image(/*uvPixelStride=*/ 2);
+    int downsamplingFactor = 2;
+    ImageProxy outputImage =
+        ImageProxyDownsampler.downsample(
+            inputImage,
+            WIDTH / downsamplingFactor,
+            HEIGHT / downsamplingFactor,
+            ImageProxyDownsampler.DownsamplingMethod.NEAREST_NEIGHBOR);
+
+    checkOutputIsNearestNeighborDownsampledInput(inputImage, outputImage, downsamplingFactor);
+  }
+
+  @Test
+  public void averagingDownsamplingBy2X_whenUVPlanesHavePixelStride1() {
+    ImageProxy inputImage = createYuv420Image(/*uvPixelStride=*/ 1);
+    int downsamplingFactor = 2;
+    ImageProxy outputImage =
+        ImageProxyDownsampler.downsample(
+            inputImage,
+            WIDTH / downsamplingFactor,
+            HEIGHT / downsamplingFactor,
+            ImageProxyDownsampler.DownsamplingMethod.AVERAGING);
+
+    checkOutputIsAveragingDownsampledInput(inputImage, outputImage, downsamplingFactor);
+  }
+
+  @Test
+  public void averagingDownsamplingBy2X_whenUVPlanesHavePixelStride2() {
+    ImageProxy inputImage = createYuv420Image(/*uvPixelStride=*/ 2);
+    int downsamplingFactor = 2;
+    ImageProxy outputImage =
+        ImageProxyDownsampler.downsample(
+            inputImage,
+            WIDTH / downsamplingFactor,
+            HEIGHT / downsamplingFactor,
+            ImageProxyDownsampler.DownsamplingMethod.AVERAGING);
+
+    checkOutputIsAveragingDownsampledInput(inputImage, outputImage, downsamplingFactor);
+  }
+
+  private static ImageProxy createYuv420Image(int uvPixelStride) {
+    ImageProxy image = mock(ImageProxy.class);
+    ImageProxy.PlaneProxy[] planes = new ImageProxy.PlaneProxy[3];
+
+    when(image.getWidth()).thenReturn(WIDTH);
+    when(image.getHeight()).thenReturn(HEIGHT);
+    when(image.getFormat()).thenReturn(ImageFormat.YUV_420_888);
+    when(image.getPlanes()).thenReturn(planes);
+
+    planes[0] = createPlaneWithRampPattern(WIDTH, HEIGHT, /*pixelStride=*/ 1, /*initialValue=*/ 0);
+    planes[1] =
+        createPlaneWithRampPattern(WIDTH / 2, HEIGHT / 2, uvPixelStride, /*initialValue=*/ 1);
+    planes[2] =
+        createPlaneWithRampPattern(WIDTH / 2, HEIGHT / 2, uvPixelStride, /*initialValue=*/ 2);
+
+    return image;
+  }
+
+  private static ImageProxy.PlaneProxy createPlaneWithRampPattern(
+      int width, int height, int pixelStride, int initialValue) {
+    return new ImageProxy.PlaneProxy() {
+      final ByteBuffer buffer =
+          createBufferWithRampPattern(width, height, pixelStride, initialValue);
+
+      @Override
+      public int getRowStride() {
+        return width * pixelStride;
+      }
+
+      @Override
+      public int getPixelStride() {
+        return pixelStride;
+      }
+
+      @Override
+      public ByteBuffer getBuffer() {
+        return buffer;
+      }
+    };
+  }
+
+  private static ByteBuffer createBufferWithRampPattern(
+      int width, int height, int pixelStride, int initialValue) {
+    int rowStride = width * pixelStride;
+    ByteBuffer buffer = ByteBuffer.allocateDirect(rowStride * height);
+    int value = initialValue;
+    for (int y = 0; y < height; ++y) {
+      for (int x = 0; x < width; ++x) {
+        buffer.position(y * rowStride + x * pixelStride);
+        buffer.put((byte) (value++ & 0xFF));
+      }
+    }
+    return buffer;
+  }
+
+  private static void checkOutputIsNearestNeighborDownsampledInput(
+      ImageProxy inputImage, ImageProxy outputImage, int downsamplingFactor) {
+    ImageProxy.PlaneProxy[] inputPlanes = inputImage.getPlanes();
+    ImageProxy.PlaneProxy[] outputPlanes = outputImage.getPlanes();
+    for (int c = 0; c < 3; ++c) {
+      ByteBuffer inputBuffer = inputPlanes[c].getBuffer();
+      ByteBuffer outputBuffer = outputPlanes[c].getBuffer();
+      inputBuffer.rewind();
+      outputBuffer.rewind();
+      int divisor = (c == 0) ? 1 : 2;
+      int inputRowStride = inputPlanes[c].getRowStride();
+      int inputPixelStride = inputPlanes[c].getPixelStride();
+      int outputRowStride = outputPlanes[c].getRowStride();
+      int outputPixelStride = outputPlanes[c].getPixelStride();
+      for (int y = 0; y < outputImage.getHeight() / divisor; ++y) {
+        for (int x = 0; x < outputImage.getWidth() / divisor; ++x) {
+          byte inputPixel =
+              inputBuffer.get(
+                  y * downsamplingFactor * inputRowStride
+                      + x * downsamplingFactor * inputPixelStride);
+          byte outputPixel = outputBuffer.get(y * outputRowStride + x * outputPixelStride);
+          assertThat(outputPixel).isEqualTo(inputPixel);
+        }
+      }
+    }
+  }
+
+  private static void checkOutputIsAveragingDownsampledInput(
+      ImageProxy inputImage, ImageProxy outputImage, int downsamplingFactor) {
+    ImageProxy.PlaneProxy[] inputPlanes = inputImage.getPlanes();
+    ImageProxy.PlaneProxy[] outputPlanes = outputImage.getPlanes();
+    for (int c = 0; c < 3; ++c) {
+      ByteBuffer inputBuffer = inputPlanes[c].getBuffer();
+      ByteBuffer outputBuffer = outputPlanes[c].getBuffer();
+      inputBuffer.rewind();
+      outputBuffer.rewind();
+      int divisor = (c == 0) ? 1 : 2;
+      int inputRowStride = inputPlanes[c].getRowStride();
+      int inputPixelStride = inputPlanes[c].getPixelStride();
+      int outputRowStride = outputPlanes[c].getRowStride();
+      int outputPixelStride = outputPlanes[c].getPixelStride();
+      for (int y = 0; y < outputImage.getHeight() / divisor; ++y) {
+        for (int x = 0; x < outputImage.getWidth() / divisor; ++x) {
+          byte inputPixelA =
+              inputBuffer.get(
+                  y * downsamplingFactor * inputRowStride
+                      + x * downsamplingFactor * inputPixelStride);
+          byte inputPixelB =
+              inputBuffer.get(
+                  y * downsamplingFactor * inputRowStride
+                      + (x * downsamplingFactor + 1) * inputPixelStride);
+          byte inputPixelC =
+              inputBuffer.get(
+                  (y * downsamplingFactor + 1) * inputRowStride
+                      + x * downsamplingFactor * inputPixelStride);
+          byte inputPixelD =
+              inputBuffer.get(
+                  (y * downsamplingFactor + 1) * inputRowStride
+                      + (x * downsamplingFactor + 1) * inputPixelStride);
+          byte averaged =
+              (byte)
+                  ((((inputPixelA & 0xFF)
+                              + (inputPixelB & 0xFF)
+                              + (inputPixelC & 0xFF)
+                              + (inputPixelD & 0xFF))
+                          / 4)
+                      & 0xFF);
+          byte outputPixel = outputBuffer.get(y * outputRowStride + x * outputPixelStride);
+          assertThat(outputPixel).isEqualTo(averaged);
+        }
+      }
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ImageSaverAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ImageSaverAndroidTest.java
new file mode 100644
index 0000000..2b986b4
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ImageSaverAndroidTest.java
@@ -0,0 +1,267 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Matchers.anyObject;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.ImageFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.support.annotation.Nullable;
+import android.util.Base64;
+import android.util.Rational;
+import androidx.camera.core.ImageSaver.OnImageSavedListener;
+import androidx.camera.core.ImageSaver.SaveError;
+import androidx.test.runner.AndroidJUnit4;
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.Semaphore;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+@RunWith(AndroidJUnit4.class)
+public class ImageSaverAndroidTest {
+
+  private static final int WIDTH = 160;
+  private static final int HEIGHT = 120;
+  private static final int Y_PIXEL_STRIDE = 1;
+  private static final int Y_ROW_STRIDE = WIDTH;
+  private static final int UV_PIXEL_STRIDE = 1;
+  private static final int UV_ROW_STRIDE = WIDTH / 2;
+
+  // The image used here has a YUV_420_888 format.
+  @Mock private final ImageProxy mockYuvImage = mock(ImageProxy.class);
+  @Mock private final ImageProxy.PlaneProxy yPlane = mock(ImageProxy.PlaneProxy.class);
+  @Mock private final ImageProxy.PlaneProxy uPlane = mock(ImageProxy.PlaneProxy.class);
+  @Mock private final ImageProxy.PlaneProxy vPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ByteBuffer yBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT);
+  private final ByteBuffer uBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4);
+  private final ByteBuffer vBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4);
+
+  @Mock private final ImageProxy mockJpegImage = mock(ImageProxy.class);
+  @Mock private final ImageProxy.PlaneProxy jpegDataPlane = mock(ImageProxy.PlaneProxy.class);
+  private final String jpegImageDataBase64 =
+      "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB"
+          + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEB"
+          + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAB4AKADASIA"
+          + "AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA"
+          + "AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3"
+          + "ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm"
+          + "p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA"
+          + "AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx"
+          + "BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK"
+          + "U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3"
+          + "uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD/AD/6"
+          + "KKK/8/8AP/P/AAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii"
+          + "gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKA"
+          + "CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAK"
+          + "KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoo"
+          + "ooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii"
+          + "gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//9k=";
+  private final ByteBuffer jpegDataBuffer =
+      ByteBuffer.wrap(Base64.decode(jpegImageDataBase64, Base64.DEFAULT));
+
+  private final Semaphore semaphore = new Semaphore(0);
+  private final ImageSaver.OnImageSavedListener mockListener =
+      mock(ImageSaver.OnImageSavedListener.class);
+  private final ImageSaver.OnImageSavedListener syncListener =
+      new OnImageSavedListener() {
+        @Override
+        public void onImageSaved(File file) {
+          mockListener.onImageSaved(file);
+          semaphore.release();
+        }
+
+        @Override
+        public void onError(SaveError saveError, String message, @Nullable Throwable cause) {
+          mockListener.onError(saveError, message, cause);
+          semaphore.release();
+        }
+      };
+
+  private HandlerThread backgroundThread;
+  private Handler backgroundHandler;
+
+  @Before
+  public void setup() {
+    // The YUV image's behavior.
+    when(mockYuvImage.getFormat()).thenReturn(ImageFormat.YUV_420_888);
+    when(mockYuvImage.getWidth()).thenReturn(WIDTH);
+    when(mockYuvImage.getHeight()).thenReturn(HEIGHT);
+
+    when(yPlane.getBuffer()).thenReturn(yBuffer);
+    when(yPlane.getPixelStride()).thenReturn(Y_PIXEL_STRIDE);
+    when(yPlane.getRowStride()).thenReturn(Y_ROW_STRIDE);
+
+    when(uPlane.getBuffer()).thenReturn(uBuffer);
+    when(uPlane.getPixelStride()).thenReturn(UV_PIXEL_STRIDE);
+    when(uPlane.getRowStride()).thenReturn(UV_ROW_STRIDE);
+
+    when(vPlane.getBuffer()).thenReturn(vBuffer);
+    when(vPlane.getPixelStride()).thenReturn(UV_PIXEL_STRIDE);
+    when(vPlane.getRowStride()).thenReturn(UV_ROW_STRIDE);
+    when(mockYuvImage.getPlanes()).thenReturn(new ImageProxy.PlaneProxy[] {yPlane, uPlane, vPlane});
+
+    // The JPEG image's behavior
+    when(mockJpegImage.getFormat()).thenReturn(ImageFormat.JPEG);
+    when(mockJpegImage.getWidth()).thenReturn(WIDTH);
+    when(mockJpegImage.getHeight()).thenReturn(HEIGHT);
+
+    when(jpegDataPlane.getBuffer()).thenReturn(jpegDataBuffer);
+    when(mockJpegImage.getPlanes()).thenReturn(new ImageProxy.PlaneProxy[] {jpegDataPlane});
+
+    // Set up a background thread/handler for callbacks
+    backgroundThread = new HandlerThread("CallbackThread");
+    backgroundThread.start();
+    backgroundHandler = new Handler(backgroundThread.getLooper());
+  }
+
+  @After
+  public void tearDown() {
+    backgroundThread.quitSafely();
+  }
+
+  private ImageSaver getDefaultImageSaver(ImageProxy image, File file) {
+    return new ImageSaver(
+        image,
+        file,
+        /*orientation=*/ 0,
+        /*reversedHorizontal=*/ false,
+        /*reversedVertical=*/ false,
+        /*location=*/ null,
+        /*cropAspectRatio=*/ null,
+        syncListener,
+        backgroundHandler);
+  }
+
+  @Test
+  public void canSaveYuvImage() throws InterruptedException, IOException {
+    File saveLocation = File.createTempFile("test", ".jpg");
+    saveLocation.deleteOnExit();
+
+    ImageSaver imageSaver = getDefaultImageSaver(mockYuvImage, saveLocation);
+
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    verify(mockListener).onImageSaved(anyObject());
+  }
+
+  @Test
+  public void canSaveJpegImage() throws InterruptedException, IOException {
+    File saveLocation = File.createTempFile("test", ".jpg");
+    saveLocation.deleteOnExit();
+
+    ImageSaver imageSaver = getDefaultImageSaver(mockJpegImage, saveLocation);
+
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    verify(mockListener).onImageSaved(anyObject());
+  }
+
+  @Test
+  public void errorCallbackWillBeCalledOnInvalidPath() throws InterruptedException, IOException {
+    // Invalid filename should cause error
+    File saveLocation = new File("/not/a/real/path.jpg");
+
+    ImageSaver imageSaver = getDefaultImageSaver(mockJpegImage, saveLocation);
+
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    verify(mockListener).onError(eq(SaveError.FILE_IO_FAILED), anyString(), anyObject());
+  }
+
+  @Test
+  public void imageIsClosedOnSuccess() throws InterruptedException, IOException {
+    File saveLocation = File.createTempFile("test", ".jpg");
+    saveLocation.deleteOnExit();
+
+    ImageSaver imageSaver = getDefaultImageSaver(mockJpegImage, saveLocation);
+
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    verify(mockJpegImage).close();
+  }
+
+  @Test
+  public void imageIsClosedOnError() throws InterruptedException, IOException {
+    // Invalid filename should cause error
+    File saveLocation = new File("/not/a/real/path.jpg");
+
+    ImageSaver imageSaver = getDefaultImageSaver(mockJpegImage, saveLocation);
+
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    verify(mockJpegImage).close();
+  }
+
+  private void imageCanBeCropped(ImageProxy image) throws InterruptedException, IOException {
+    File saveLocation = File.createTempFile("test", ".jpg");
+    saveLocation.deleteOnExit();
+
+    Rational viewRatio = new Rational(1, 1);
+
+    ImageSaver imageSaver = new ImageSaver(
+        image,
+        saveLocation,
+        /*orientation=*/ 0,
+        /*reversedHorizontal=*/ false,
+        /*reversedVertical=*/ false,
+        /*location=*/ null,
+        /*cropAspectRatio=*/ viewRatio,
+        syncListener,
+        backgroundHandler
+    );
+    imageSaver.run();
+
+    semaphore.acquire();
+
+    Bitmap bitmap = BitmapFactory.decodeFile(saveLocation.getPath());
+    assertThat(bitmap.getWidth()).isEqualTo(bitmap.getHeight());
+  }
+
+  @Test
+  public void jpegImageCanBeCropped() throws InterruptedException, IOException {
+    imageCanBeCropped(mockJpegImage);
+  }
+
+  @Test
+  public void yuvImageCanBeCropped() throws InterruptedException, IOException {
+    imageCanBeCropped(mockYuvImage);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ImmediateSurfaceAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ImmediateSurfaceAndroidTest.java
new file mode 100644
index 0000000..3a2248a
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ImmediateSurfaceAndroidTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public final class ImmediateSurfaceAndroidTest {
+  Surface mockSurface = Mockito.mock(Surface.class);
+
+  @Test
+  public void getSurface_returnsInstance() throws ExecutionException, InterruptedException {
+    ImmediateSurface immediateSurface = new ImmediateSurface(mockSurface);
+
+    ListenableFuture<Surface> surfaceListenableFuture = immediateSurface.getSurface();
+
+    assertThat(surfaceListenableFuture.get()).isSameAs(mockSurface);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/IoExecutorAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/IoExecutorAndroidTest.java
new file mode 100644
index 0000000..2b1b953
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/IoExecutorAndroidTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.support.annotation.GuardedBy;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.concurrent.Executor;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class IoExecutorAndroidTest {
+
+  private Executor ioExecutor;
+
+  private enum RunnableState {
+    CLEAR,
+    RUNNABLE1_WAITING,
+    RUNNABLE1_FINISHED,
+    RUNNABLE2_FINISHED
+  }
+
+  private Lock lock = new ReentrantLock();
+  private Condition condition = lock.newCondition();
+
+  @GuardedBy("lock")
+  private RunnableState state = RunnableState.CLEAR;
+
+  private final Runnable runnable1 =
+      () -> {
+        lock.lock();
+        try {
+          state = RunnableState.RUNNABLE1_WAITING;
+          condition.signalAll();
+          while (state != RunnableState.CLEAR) {
+            condition.await();
+          }
+
+          state = RunnableState.RUNNABLE1_FINISHED;
+          condition.signalAll();
+        } catch (InterruptedException e) {
+          throw new RuntimeException("Thread interrupted unexpectedly", e);
+        } finally {
+          lock.unlock();
+        }
+      };
+
+  private final Runnable runnable2 =
+      () -> {
+        lock.lock();
+        try {
+          while (state != RunnableState.RUNNABLE1_WAITING) {
+            condition.await();
+          }
+
+          state = RunnableState.RUNNABLE2_FINISHED;
+          condition.signalAll();
+        } catch (InterruptedException e) {
+          throw new RuntimeException("Thread interrupted unexpectedly", e);
+        } finally {
+          lock.unlock();
+        }
+      };
+
+  private final Runnable simpleRunnable1 =
+      () -> {
+        lock.lock();
+        try {
+          state = RunnableState.RUNNABLE1_FINISHED;
+          condition.signalAll();
+        } finally {
+          lock.unlock();
+        }
+      };
+
+  @Before
+  public void setup() {
+    lock.lock();
+    try {
+      state = RunnableState.CLEAR;
+    } finally {
+      lock.unlock();
+    }
+    ioExecutor = IoExecutor.getInstance();
+  }
+
+  @Test(timeout = 2000)
+  public void canRunRunnable() throws InterruptedException {
+    ioExecutor.execute(simpleRunnable1);
+    lock.lock();
+    try {
+      while (state != RunnableState.RUNNABLE1_FINISHED) {
+        condition.await();
+      }
+    } finally {
+      lock.unlock();
+    }
+
+    // No need to check anything here. Completing this method should signal success.
+  }
+
+  @Test(timeout = 2000)
+  public void canRunMultipleRunnableInParallel() throws InterruptedException {
+    ioExecutor.execute(runnable1);
+    ioExecutor.execute(runnable2);
+
+    lock.lock();
+    try {
+      // runnable2 cannot finish until runnable1 has started
+      while (state != RunnableState.RUNNABLE2_FINISHED) {
+        condition.await();
+      }
+
+      // Allow runnable1 to finish
+      state = RunnableState.CLEAR;
+      condition.signalAll();
+
+      while (state != RunnableState.RUNNABLE1_FINISHED) {
+        condition.await();
+      }
+    } finally {
+      lock.unlock();
+    }
+
+    // No need to check anything here. Completing this method should signal success.
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/QueuedImageReaderProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/QueuedImageReaderProxyAndroidTest.java
new file mode 100644
index 0000000..d5c3b5e
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/QueuedImageReaderProxyAndroidTest.java
@@ -0,0 +1,306 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.ImageFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Semaphore;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class QueuedImageReaderProxyAndroidTest {
+  private static final int IMAGE_WIDTH = 640;
+  private static final int IMAGE_HEIGHT = 480;
+  private static final int IMAGE_FORMAT = ImageFormat.YUV_420_888;
+  private static final int MAX_IMAGES = 10;
+
+  private final Surface surface = mock(Surface.class);
+  private HandlerThread handlerThread;
+  private Handler handler;
+  private QueuedImageReaderProxy imageReaderProxy;
+
+  @Before
+  public void setUp() {
+    handlerThread = new HandlerThread("background");
+    handlerThread.start();
+    handler = new Handler(handlerThread.getLooper());
+    imageReaderProxy =
+        new QueuedImageReaderProxy(IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_FORMAT, MAX_IMAGES, surface);
+  }
+
+  @After
+  public void tearDown() {
+    handlerThread.quitSafely();
+  }
+
+  @Test
+  public void enqueueImage_incrementsQueueSize() {
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+
+    assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(2);
+  }
+
+  @Test
+  public void enqueueImage_doesNotIncreaseSizeBeyondMaxImages() {
+    // Exceed the queue's capacity by 2.
+    for (int i = 0; i < MAX_IMAGES + 2; ++i) {
+      imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    }
+
+    assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(MAX_IMAGES);
+  }
+
+  @Test
+  public void enqueueImage_closesImagesWhichAreNotEnqueued_doesNotCloseOtherImages() {
+    // Exceed the queue's capacity by 2.
+    List<ConcreteImageProxy> images = new ArrayList<>(MAX_IMAGES + 2);
+    for (int i = 0; i < MAX_IMAGES + 2; ++i) {
+      images.add(createForwardingImageProxy());
+      imageReaderProxy.enqueueImage(images.get(i));
+    }
+
+    // Last two images should not be enqueued and should be closed.
+    assertThat(images.get(MAX_IMAGES).isClosed()).isTrue();
+    assertThat(images.get(MAX_IMAGES + 1).isClosed()).isTrue();
+    // All other images should be enqueued and open.
+    for (int i = 0; i < MAX_IMAGES; ++i) {
+      assertThat(images.get(i).isClosed()).isFalse();
+    }
+  }
+
+  @Test(timeout = 2000)
+  public void closedImages_reduceQueueSize() throws InterruptedException {
+    // Fill up to the queue's capacity.
+    Semaphore  Semaphore(/*permits=*/ 0);
+    for (int i = 0; i < MAX_IMAGES; ++i) {
+      ForwardingImageProxy image = createSemaphoreReleasingOnCloseImageProxy(onCloseSemaphore);
+      imageReaderProxy.enqueueImage(image);
+    }
+
+    imageReaderProxy.acquireNextImage().close();
+    imageReaderProxy.acquireNextImage().close();
+    onCloseSemaphore.acquire(/*permits=*/ 2);
+
+    assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(MAX_IMAGES - 2);
+  }
+
+  @Test(timeout = 2000)
+  public void closedImage_allowsNewImageToBeEnqueued() throws InterruptedException {
+    // Fill up to the queue's capacity.
+    Semaphore  Semaphore(/*permits=*/ 0);
+    for (int i = 0; i < MAX_IMAGES; ++i) {
+      ForwardingImageProxy image = createSemaphoreReleasingOnCloseImageProxy(onCloseSemaphore);
+      imageReaderProxy.enqueueImage(image);
+    }
+
+    imageReaderProxy.acquireNextImage().close();
+    onCloseSemaphore.acquire();
+
+    ConcreteImageProxy lastImageProxy = createForwardingImageProxy();
+    imageReaderProxy.enqueueImage(lastImageProxy);
+
+    // Last image should be enqueued and open.
+    assertThat(lastImageProxy.isClosed()).isFalse();
+  }
+
+  @Test
+  public void enqueueImage_invokesListenerCallback() {
+    ImageReaderProxy.OnImageAvailableListener listener =
+        mock(ImageReaderProxy.OnImageAvailableListener.class);
+    imageReaderProxy.setOnImageAvailableListener(listener, handler);
+
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+
+    verify(listener, timeout(2000).times(2)).onImageAvailable(imageReaderProxy);
+  }
+
+  @Test
+  public void acquireLatestImage_returnsNull_whenQueueIsEmpty() {
+    assertThat(imageReaderProxy.acquireLatestImage()).isNull();
+  }
+
+  @Test
+  public void acquireLatestImage_returnsLastImage_reducesQueueSizeToOne() {
+    final int availableImages = 5;
+    List<ForwardingImageProxy> images = new ArrayList<>(availableImages);
+    for (int i = 0; i < availableImages; ++i) {
+      images.add(createForwardingImageProxy());
+      imageReaderProxy.enqueueImage(images.get(i));
+    }
+
+    ImageProxy lastImage = images.get(availableImages - 1);
+    assertThat(imageReaderProxy.acquireLatestImage()).isEqualTo(lastImage);
+    assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(1);
+  }
+
+  @Test
+  public void acquireLatestImage_throwsException_whenAllImagesWerePreviouslyAcquired() {
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.acquireNextImage();
+
+    assertThrows(IllegalStateException.class, () -> imageReaderProxy.acquireLatestImage());
+  }
+
+  @Test
+  public void acquireNextImage_returnsNull_whenQueueIsEmpty() {
+    assertThat(imageReaderProxy.acquireNextImage()).isNull();
+  }
+
+  @Test
+  public void acquireNextImage_returnsNextImage_doesNotChangeQueueSize() {
+    final int availableImages = 5;
+    List<ForwardingImageProxy> images = new ArrayList<>(availableImages);
+    for (int i = 0; i < availableImages; ++i) {
+      images.add(createForwardingImageProxy());
+      imageReaderProxy.enqueueImage(images.get(i));
+    }
+
+    for (int i = 0; i < availableImages; ++i) {
+      assertThat(imageReaderProxy.acquireNextImage()).isEqualTo(images.get(i));
+    }
+    assertThat(imageReaderProxy.getCurrentImages()).isEqualTo(availableImages);
+  }
+
+  @Test
+  public void acquireNextImage_throwsException_whenAllImagesWerePreviouslyAcquired() {
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.acquireNextImage();
+
+    assertThrows(IllegalStateException.class, () -> imageReaderProxy.acquireNextImage());
+  }
+
+  @Test
+  public void close_closesAnyImagesStillInQueue() {
+    ConcreteImageProxy image0 = createForwardingImageProxy();
+    ConcreteImageProxy image1 = createForwardingImageProxy();
+    imageReaderProxy.enqueueImage(image0);
+    imageReaderProxy.enqueueImage(image1);
+
+    imageReaderProxy.close();
+
+    assertThat(image0.isClosed()).isTrue();
+    assertThat(image1.isClosed()).isTrue();
+  }
+
+  @Test
+  public void close_notifiesOnCloseListeners() {
+    QueuedImageReaderProxy.OnReaderCloseListener listenerA =
+        mock(QueuedImageReaderProxy.OnReaderCloseListener.class);
+    QueuedImageReaderProxy.OnReaderCloseListener listenerB =
+        mock(QueuedImageReaderProxy.OnReaderCloseListener.class);
+    imageReaderProxy.addOnReaderCloseListener(listenerA);
+    imageReaderProxy.addOnReaderCloseListener(listenerB);
+
+    imageReaderProxy.close();
+
+    verify(listenerA, times(1)).onReaderClose(imageReaderProxy);
+    verify(listenerB, times(1)).onReaderClose(imageReaderProxy);
+  }
+
+  @Test
+  public void acquireLatestImage_throwsException_afterReaderIsClosed() {
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.close();
+
+    assertThrows(IllegalStateException.class, () -> imageReaderProxy.acquireLatestImage());
+  }
+
+  @Test
+  public void acquireNextImage_throwsException_afterReaderIsClosed() {
+    imageReaderProxy.enqueueImage(createForwardingImageProxy());
+    imageReaderProxy.close();
+
+    assertThrows(IllegalStateException.class, () -> imageReaderProxy.acquireNextImage());
+  }
+
+  @Test
+  public void getHeight_returnsFixedHeight() {
+    assertThat(imageReaderProxy.getHeight()).isEqualTo(IMAGE_HEIGHT);
+  }
+
+  @Test
+  public void getWidth_returnsFixedWidth() {
+    assertThat(imageReaderProxy.getWidth()).isEqualTo(IMAGE_WIDTH);
+  }
+
+  @Test
+  public void getImageFormat_returnsFixedFormat() {
+    assertThat(imageReaderProxy.getImageFormat()).isEqualTo(IMAGE_FORMAT);
+  }
+
+  @Test
+  public void getMaxImages_returnsFixedCapacity() {
+    assertThat(imageReaderProxy.getMaxImages()).isEqualTo(MAX_IMAGES);
+  }
+
+  private static ImageProxy createMockImageProxy() {
+    ImageProxy image = mock(ImageProxy.class);
+    when(image.getWidth()).thenReturn(IMAGE_WIDTH);
+    when(image.getHeight()).thenReturn(IMAGE_HEIGHT);
+    when(image.getFormat()).thenReturn(IMAGE_FORMAT);
+    return image;
+  }
+
+  private static ConcreteImageProxy createSemaphoreReleasingOnCloseImageProxy(Semaphore semaphore) {
+    ConcreteImageProxy image = createForwardingImageProxy();
+    image.addOnImageCloseListener(
+        closedImage -> {
+          semaphore.release();
+        });
+    return image;
+  }
+
+  private static ConcreteImageProxy createForwardingImageProxy() {
+    return new ConcreteImageProxy(createMockImageProxy());
+  }
+
+  private static final class ConcreteImageProxy extends ForwardingImageProxy {
+    private boolean isClosed = false;
+
+    ConcreteImageProxy(ImageProxy image) {
+      super(image);
+    }
+
+    @Override
+    public synchronized void close() {
+      super.close();
+      isClosed = true;
+    }
+
+    public synchronized boolean isClosed() {
+      return isClosed;
+    }
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/ReferenceCountedImageProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/ReferenceCountedImageProxyAndroidTest.java
new file mode 100644
index 0000000..94f75f7
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/ReferenceCountedImageProxyAndroidTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import androidx.test.runner.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ReferenceCountedImageProxyAndroidTest {
+  private static final int WIDTH = 640;
+  private static final int HEIGHT = 480;
+
+  // Assume the image has YUV_420_888 format.
+  private final ImageProxy image = mock(ImageProxy.class);
+  private final ImageProxy.PlaneProxy yPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ImageProxy.PlaneProxy uPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ImageProxy.PlaneProxy vPlane = mock(ImageProxy.PlaneProxy.class);
+  private final ByteBuffer yBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT);
+  private final ByteBuffer uBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4);
+  private final ByteBuffer vBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4);
+  private ReferenceCountedImageProxy imageProxy;
+
+  @Before
+  public void setUp() {
+    when(image.getWidth()).thenReturn(WIDTH);
+    when(image.getHeight()).thenReturn(HEIGHT);
+    when(yPlane.getBuffer()).thenReturn(yBuffer);
+    when(uPlane.getBuffer()).thenReturn(uBuffer);
+    when(vPlane.getBuffer()).thenReturn(vBuffer);
+    when(image.getPlanes()).thenReturn(new ImageProxy.PlaneProxy[] {yPlane, uPlane, vPlane});
+    imageProxy = new ReferenceCountedImageProxy(image);
+  }
+
+  @Test
+  public void getReferenceCount_returnsOne_afterConstruction() {
+    assertThat(imageProxy.getReferenceCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void fork_incrementsReferenceCount() {
+    imageProxy.fork();
+    imageProxy.fork();
+
+    assertThat(imageProxy.getReferenceCount()).isEqualTo(3);
+  }
+
+  @Test
+  public void close_decrementsReferenceCount() {
+    ImageProxy forkedImage0 = imageProxy.fork();
+    ImageProxy forkedImage1 = imageProxy.fork();
+
+    forkedImage0.close();
+    forkedImage1.close();
+
+    assertThat(imageProxy.getReferenceCount()).isEqualTo(1);
+    verify(image, never()).close();
+  }
+
+  @Test
+  public void close_closesBaseImage_whenReferenceCountHitsZero() {
+    ImageProxy forkedImage0 = imageProxy.fork();
+    ImageProxy forkedImage1 = imageProxy.fork();
+
+    forkedImage0.close();
+    forkedImage1.close();
+    imageProxy.close();
+
+    assertThat(imageProxy.getReferenceCount()).isEqualTo(0);
+    verify(image, times(1)).close();
+  }
+
+  @Test
+  public void close_decrementsReferenceCountOnlyOnce() {
+    ImageProxy forkedImage = imageProxy.fork();
+
+    forkedImage.close();
+    forkedImage.close();
+
+    assertThat(imageProxy.getReferenceCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void fork_returnsNull_whenBaseImageIsClosed() {
+    imageProxy.close();
+
+    ImageProxy forkedImage = imageProxy.fork();
+
+    assertThat(forkedImage).isNull();
+  }
+
+  @Test
+  public void concurrentAccessForTwoForkedImagesOnTwoThreads() throws InterruptedException {
+    final ImageProxy forkedImage0 = imageProxy.fork();
+    final ImageProxy forkedImage1 = imageProxy.fork();
+
+    Thread thread0 =
+        new Thread() {
+          @Override
+          public void run() {
+            forkedImage0.getWidth();
+            forkedImage0.getHeight();
+            ImageProxy.PlaneProxy[] planes = forkedImage0.getPlanes();
+            for (ImageProxy.PlaneProxy plane : planes) {
+              ByteBuffer buffer = plane.getBuffer();
+              for (int i = 0; i < buffer.capacity(); ++i) {
+                buffer.get(i);
+              }
+            }
+          }
+        };
+    Thread thread1 =
+        new Thread() {
+          @Override
+          public void run() {
+            forkedImage1.getWidth();
+            forkedImage1.getHeight();
+            ImageProxy.PlaneProxy[] planes = forkedImage1.getPlanes();
+            for (ImageProxy.PlaneProxy plane : planes) {
+              ByteBuffer buffer = plane.getBuffer();
+              for (int i = 0; i < buffer.capacity(); ++i) {
+                buffer.get(i);
+              }
+            }
+          }
+        };
+
+    thread0.start();
+    thread1.start();
+    thread0.join();
+    thread1.join();
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/SessionConfigurationAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/SessionConfigurationAndroidTest.java
new file mode 100644
index 0000000..77aa84f
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/SessionConfigurationAndroidTest.java
@@ -0,0 +1,285 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.CaptureRequest.Key;
+import android.view.Surface;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public class SessionConfigurationAndroidTest {
+  private DeferrableSurface mockSurface0;
+  private DeferrableSurface mockSurface1;
+
+  @Before
+  public void setup() {
+    mockSurface0 = new ImmediateSurface(Mockito.mock(Surface.class));
+    mockSurface1 = new ImmediateSurface(Mockito.mock(Surface.class));
+  }
+
+  @Test
+  public void builderSetTemplate() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    assertThat(sessionConfiguration.getTemplateType()).isEqualTo(CameraDevice.TEMPLATE_PREVIEW);
+  }
+
+  @Test
+  public void builderAddSurface() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    List<DeferrableSurface> surfaces = sessionConfiguration.getSurfaces();
+
+    assertThat(surfaces).hasSize(1);
+    assertThat(surfaces).contains(mockSurface0);
+  }
+
+  @Test
+  public void builderAddNonRepeatingSurface() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addNonRepeatingSurface(mockSurface0);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    List<DeferrableSurface> surfaces = sessionConfiguration.getSurfaces();
+    List<DeferrableSurface> repeatingSurfaces =
+        sessionConfiguration.getCaptureRequestConfiguration().getSurfaces();
+
+    assertThat(surfaces).hasSize(1);
+    assertThat(surfaces).contains(mockSurface0);
+    assertThat(repeatingSurfaces).isEmpty();
+    assertThat(repeatingSurfaces).doesNotContain(mockSurface0);
+  }
+
+  @Test
+  public void builderAddSurfaceContainsRepeatingSurface() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    builder.addNonRepeatingSurface(mockSurface1);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    List<Surface> surfaces = DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces());
+    List<Surface> repeatingSurfaces =
+        DeferrableSurfaces.surfaceList(
+            sessionConfiguration.getCaptureRequestConfiguration().getSurfaces());
+
+    assertThat(surfaces.size()).isAtLeast(repeatingSurfaces.size());
+    assertThat(surfaces).containsAllIn(repeatingSurfaces);
+  }
+
+  @Test
+  public void builderRemoveSurface() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    builder.removeSurface(mockSurface0);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    List<Surface> surfaces = DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces());
+    assertThat(surfaces).isEmpty();
+  }
+
+  @Test
+  public void builderClearSurface() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addSurface(mockSurface0);
+    builder.clearSurfaces();
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    List<Surface> surfaces = DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces());
+    assertThat(surfaces.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void builderAddCharacteristic() {
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+
+    builder.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+    SessionConfiguration sessionConfiguration = builder.build();
+
+    Map<Key<?>, CaptureRequestParameter<?>> parameterMap =
+        sessionConfiguration.getCameraCharacteristics();
+
+    assertThat(parameterMap.containsKey(CaptureRequest.CONTROL_AF_MODE)).isTrue();
+    assertThat(parameterMap)
+        .containsEntry(
+            CaptureRequest.CONTROL_AF_MODE,
+            CaptureRequestParameter.create(
+                CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO));
+  }
+
+  @Test
+  public void conflictingTemplate() {
+    SessionConfiguration.Builder builderPreview = new SessionConfiguration.Builder();
+    builderPreview.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    SessionConfiguration sessionConfigurationPreview = builderPreview.build();
+    SessionConfiguration.Builder builderZsl = new SessionConfiguration.Builder();
+    builderZsl.setTemplateType(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG);
+    SessionConfiguration sessionConfigurationZsl = builderZsl.build();
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+
+    validatingBuilder.add(sessionConfigurationPreview);
+    validatingBuilder.add(sessionConfigurationZsl);
+
+    assertThat(validatingBuilder.isValid()).isFalse();
+  }
+
+  @Test
+  public void conflictingCharacteristics() {
+    SessionConfiguration.Builder builderAfAuto = new SessionConfiguration.Builder();
+    builderAfAuto.addCharacteristic(
+        CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+    SessionConfiguration sessionConfigurationAfAuto = builderAfAuto.build();
+    SessionConfiguration.Builder builderAfOff = new SessionConfiguration.Builder();
+    builderAfOff.addCharacteristic(
+        CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF);
+    SessionConfiguration sessionConfigurationAfOff = builderAfOff.build();
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+
+    validatingBuilder.add(sessionConfigurationAfAuto);
+    validatingBuilder.add(sessionConfigurationAfOff);
+
+    assertThat(validatingBuilder.isValid()).isFalse();
+  }
+
+  @Test
+  public void combineTwoSessionsValid() {
+    SessionConfiguration.Builder builder0 = new SessionConfiguration.Builder();
+    builder0.addSurface(mockSurface0);
+    builder0.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder0.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    SessionConfiguration.Builder builder1 = new SessionConfiguration.Builder();
+    builder1.addSurface(mockSurface1);
+    builder1.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder1.addCharacteristic(
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE_AUTO);
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+    validatingBuilder.add(builder0.build());
+    validatingBuilder.add(builder1.build());
+
+    assertThat(validatingBuilder.isValid()).isTrue();
+  }
+
+  @Test
+  public void combineTwoSessionsTemplate() {
+    SessionConfiguration.Builder builder0 = new SessionConfiguration.Builder();
+    builder0.addSurface(mockSurface0);
+    builder0.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder0.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    SessionConfiguration.Builder builder1 = new SessionConfiguration.Builder();
+    builder1.addSurface(mockSurface1);
+    builder1.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder1.addCharacteristic(
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE_AUTO);
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+    validatingBuilder.add(builder0.build());
+    validatingBuilder.add(builder1.build());
+
+    SessionConfiguration sessionConfiguration = validatingBuilder.build();
+
+    assertThat(sessionConfiguration.getTemplateType()).isEqualTo(CameraDevice.TEMPLATE_PREVIEW);
+  }
+
+  @Test
+  public void combineTwoSessionsSurfaces() {
+    SessionConfiguration.Builder builder0 = new SessionConfiguration.Builder();
+    builder0.addSurface(mockSurface0);
+    builder0.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder0.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    SessionConfiguration.Builder builder1 = new SessionConfiguration.Builder();
+    builder1.addSurface(mockSurface1);
+    builder1.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder1.addCharacteristic(
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE_AUTO);
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+    validatingBuilder.add(builder0.build());
+    validatingBuilder.add(builder1.build());
+
+    SessionConfiguration sessionConfiguration = validatingBuilder.build();
+
+    List<DeferrableSurface> surfaces = sessionConfiguration.getSurfaces();
+    assertThat(surfaces).containsExactly(mockSurface0, mockSurface1);
+  }
+
+  @Test
+  public void combineTwoSessionsCharacteristics() {
+    SessionConfiguration.Builder builder0 = new SessionConfiguration.Builder();
+    builder0.addSurface(mockSurface0);
+    builder0.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder0.addCharacteristic(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO);
+
+    SessionConfiguration.Builder builder1 = new SessionConfiguration.Builder();
+    builder1.addSurface(mockSurface1);
+    builder1.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+    builder1.addCharacteristic(
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+        CaptureRequest.CONTROL_AE_ANTIBANDING_MODE_AUTO);
+
+    SessionConfiguration.ValidatingBuilder validatingBuilder =
+        new SessionConfiguration.ValidatingBuilder();
+    validatingBuilder.add(builder0.build());
+    validatingBuilder.add(builder1.build());
+
+    SessionConfiguration sessionConfiguration = validatingBuilder.build();
+
+    Map<Key<?>, CaptureRequestParameter<?>> parameterMap =
+        sessionConfiguration.getCameraCharacteristics();
+    assertThat(parameterMap)
+        .containsExactly(
+            CaptureRequest.CONTROL_AF_MODE,
+            CaptureRequestParameter.create(
+                CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO),
+            CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+            CaptureRequestParameter.create(
+                CaptureRequest.CONTROL_AE_ANTIBANDING_MODE,
+                CaptureRequest.CONTROL_AE_ANTIBANDING_MODE_AUTO));
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/SingleCloseImageProxyAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/SingleCloseImageProxyAndroidTest.java
new file mode 100644
index 0000000..2519b8b
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/SingleCloseImageProxyAndroidTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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 androidx.camera.core;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class SingleCloseImageProxyAndroidTest {
+
+  private final ImageProxy imageProxy = mock(ImageProxy.class);
+  private SingleCloseImageProxy singleCloseImageProxy;
+
+  @Before
+  public void setUp() {
+    singleCloseImageProxy = new SingleCloseImageProxy(imageProxy);
+  }
+
+  @Test
+  public void wrappedImageIsClosedOnce_whenWrappingImageIsClosedOnce() {
+    singleCloseImageProxy.close();
+
+    verify(imageProxy, times(1)).close();
+  }
+
+  @Test
+  public void wrappedImageIsClosedOnce_whenWrappingImageIsClosedTwice() {
+    singleCloseImageProxy.close();
+    singleCloseImageProxy.close();
+
+    verify(imageProxy, times(1)).close();
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/StreamConfigurationMapUtil.java b/camera/core/src/androidTest/java/androidx/camera/core/StreamConfigurationMapUtil.java
new file mode 100644
index 0000000..07862a1
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/StreamConfigurationMapUtil.java
@@ -0,0 +1,211 @@
+/*
+ * 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 androidx.camera.core;
+
+import android.graphics.ImageFormat;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.os.Build;
+import android.util.Size;
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+
+/** Utility functions to obtain fake {@link StreamConfigurationMap} for testing */
+public final class StreamConfigurationMapUtil {
+  /**
+   * Generates fake StreamConfigurationMap for testing usage.
+   *
+   * @return a fake {@link StreamConfigurationMap} object
+   */
+  public static StreamConfigurationMap generateFakeStreamConfigurationMap() {
+    /**
+     * Defined in StreamConfigurationMap.java: 0x21 is internal defined legal format corresponding
+     * to ImageFormat.JPEG. 0x22 is internal defined legal format IMPLEMENTATION_DEFINED and at
+     * least one stream configuration for IMPLEMENTATION_DEFINED(0x22) must exist, otherwise, there
+     * will be AssertionError threw. 0x22 is also mapped to ImageFormat.PRIVATE after Android level
+     * 23.
+     */
+    int[] supportedFormats =
+        new int[] {
+          ImageFormat.YUV_420_888,
+          ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_JPEG,
+          ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
+        };
+    Size[] supportedSizes =
+        new Size[] {
+          new Size(4032, 3024),
+          new Size(3840, 2160),
+          new Size(1920, 1080),
+          new Size(640, 480),
+          new Size(320, 240),
+          new Size(320, 180)
+        };
+
+    return generateFakeStreamConfigurationMap(supportedFormats, supportedSizes);
+  }
+
+  /**
+   * Generates fake StreamConfigurationMap for testing usage.
+   *
+   * @param supportedFormats The supported {@link ImageFormat} list to be added
+   * @param supportedSizes The supported sizes to be added
+   * @return a fake {@link StreamConfigurationMap} object
+   */
+  public static StreamConfigurationMap generateFakeStreamConfigurationMap(
+      int[] supportedFormats, Size[] supportedSizes) {
+    StreamConfigurationMap map;
+
+    // TODO(b/123938482): Remove usage of reflection in this class
+    Class<?> streamConfigurationClass;
+    Class<?> streamConfigurationDurationClass;
+    Class<?> highSpeedVideoConfigurationClass;
+    Class<?> reprocessFormatsMapClass;
+
+    try {
+      streamConfigurationClass =
+          Class.forName("android.hardware.camera2.params.StreamConfiguration");
+      streamConfigurationDurationClass =
+          Class.forName("android.hardware.camera2.params.StreamConfigurationDuration");
+      highSpeedVideoConfigurationClass =
+          Class.forName("android.hardware.camera2.params.HighSpeedVideoConfiguration");
+      reprocessFormatsMapClass =
+          Class.forName("android.hardware.camera2.params.ReprocessFormatsMap");
+    } catch (ClassNotFoundException e) {
+      throw new AssertionError(
+          "Class can not be found when trying to generate a StreamConfigurationMap object.", e);
+    }
+
+    Constructor<?> streamConfigurationMapConstructor;
+    Constructor<?> streamConfigurationConstructor;
+    Constructor<?> streamConfigurationDurationConstructor;
+
+    try {
+      if (Build.VERSION.SDK_INT >= 23) {
+        streamConfigurationMapConstructor =
+            StreamConfigurationMap.class.getDeclaredConstructor(
+                Array.newInstance(streamConfigurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(highSpeedVideoConfigurationClass, 1).getClass(),
+                reprocessFormatsMapClass,
+                boolean.class);
+      } else {
+        streamConfigurationMapConstructor =
+            StreamConfigurationMap.class.getDeclaredConstructor(
+                Array.newInstance(streamConfigurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(streamConfigurationDurationClass, 1).getClass(),
+                Array.newInstance(highSpeedVideoConfigurationClass, 1).getClass());
+      }
+
+      streamConfigurationConstructor =
+          streamConfigurationClass.getDeclaredConstructor(
+              int.class, int.class, int.class, boolean.class);
+
+      streamConfigurationDurationConstructor =
+          streamConfigurationDurationClass.getDeclaredConstructor(
+              int.class, int.class, int.class, long.class);
+    } catch (NoSuchMethodException e) {
+      throw new AssertionError(
+          "Constructor can not be found when trying to generate a StreamConfigurationMap object.",
+          e);
+    }
+
+    Object configurationArray =
+        Array.newInstance(
+            streamConfigurationClass, supportedFormats.length * supportedSizes.length);
+    Object minFrameDurationArray = Array.newInstance(streamConfigurationDurationClass, 1);
+    Object stallDurationArray = Array.newInstance(streamConfigurationDurationClass, 1);
+    Object depthConfigurationArray = Array.newInstance(streamConfigurationClass, 1);
+    Object depthMinFrameDurationArray = Array.newInstance(streamConfigurationDurationClass, 1);
+    Object depthStallDurationArray = Array.newInstance(streamConfigurationDurationClass, 1);
+
+    try {
+      for (int i = 0; i < supportedFormats.length; i++) {
+        for (int j = 0; j < supportedSizes.length; j++) {
+          Array.set(
+              configurationArray,
+              i * supportedSizes.length + j,
+              streamConfigurationConstructor.newInstance(
+                  supportedFormats[i],
+                  supportedSizes[j].getWidth(),
+                  supportedSizes[j].getHeight(),
+                  false));
+        }
+      }
+
+      Array.set(
+          minFrameDurationArray,
+          0,
+          streamConfigurationDurationConstructor.newInstance(
+              ImageFormat.YUV_420_888, 1920, 1080, 0));
+
+      Array.set(
+          stallDurationArray,
+          0,
+          streamConfigurationDurationConstructor.newInstance(
+              ImageFormat.YUV_420_888, 1920, 1080, 0));
+
+      // Need depth configuration to create the object successfully
+      // 0x24 is internal format type of HAL_PIXEL_FORMAT_RAW_OPAQUE
+      Array.set(
+          depthConfigurationArray,
+          0,
+          streamConfigurationConstructor.newInstance(0x24, 1920, 1080, false));
+
+      Array.set(
+          depthMinFrameDurationArray,
+          0,
+          streamConfigurationDurationConstructor.newInstance(0x24, 1920, 1080, 0));
+
+      Array.set(
+          depthStallDurationArray,
+          0,
+          streamConfigurationDurationConstructor.newInstance(0x24, 1920, 1080, 0));
+
+      if (Build.VERSION.SDK_INT >= 23) {
+        map =
+            (StreamConfigurationMap)
+                streamConfigurationMapConstructor.newInstance(
+                    configurationArray,
+                    minFrameDurationArray,
+                    stallDurationArray,
+                    depthConfigurationArray,
+                    depthMinFrameDurationArray,
+                    depthStallDurationArray,
+                    null,
+                    null,
+                    false);
+      } else {
+        map =
+            (StreamConfigurationMap)
+                streamConfigurationMapConstructor.newInstance(
+                    configurationArray, minFrameDurationArray, stallDurationArray, null);
+      }
+
+    } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
+      throw new AssertionError(
+          "Failed to create new instance when trying to generate a StreamConfigurationMap object.",
+          e);
+    }
+
+    return map;
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/UseCaseAttachStateAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseAttachStateAndroidTest.java
new file mode 100644
index 0000000..6fad240
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseAttachStateAndroidTest.java
@@ -0,0 +1,335 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraDevice;
+import android.util.Size;
+import android.view.Surface;
+import androidx.camera.core.CameraX.LensFacing;
+import androidx.camera.testing.fakes.FakeAppConfiguration;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public class UseCaseAttachStateAndroidTest {
+  private final LensFacing cameraLensFacing0 = LensFacing.BACK;
+  private final LensFacing cameraLensFacing1 = LensFacing.FRONT;
+  private final CameraDevice mockCameraDevice = Mockito.mock(CameraDevice.class);
+  private final CameraCaptureSession mockCameraCaptureSession =
+      Mockito.mock(CameraCaptureSession.class);
+
+  private String cameraId;
+
+  private static class TestUseCase extends FakeUseCase {
+    private final Surface surface = Mockito.mock(Surface.class);
+    private final CameraDevice.StateCallback deviceStateCallback =
+        Mockito.mock(CameraDevice.StateCallback.class);
+    private final CameraCaptureSession.StateCallback sessionStateCallback =
+        Mockito.mock(CameraCaptureSession.StateCallback.class);
+    private final CameraCaptureCallback cameraCaptureCallback =
+        Mockito.mock(CameraCaptureCallback.class);
+
+    TestUseCase(FakeUseCaseConfiguration configuration, String cameraId) {
+      super(configuration);
+      Map<String, Size> suggestedResolutionMap = new HashMap<>();
+      suggestedResolutionMap.put(cameraId, new Size(640, 480));
+      updateSuggestedResolution(suggestedResolutionMap);
+    }
+
+    @Override
+    protected Map<String, Size> onSuggestedResolutionUpdated(
+        Map<String, Size> suggestedResolutionMap) {
+      SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+      builder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
+      builder.addSurface(new ImmediateSurface(surface));
+      builder.setDeviceStateCallback(deviceStateCallback);
+      builder.setSessionStateCallback(sessionStateCallback);
+      builder.setCameraCaptureCallback(cameraCaptureCallback);
+
+      LensFacing lensFacing =
+          ((CameraDeviceConfiguration) getUseCaseConfiguration()).getLensFacing();
+      try {
+        String cameraId = CameraX.getCameraWithLensFacing(lensFacing);
+        attachToCamera(cameraId, builder.build());
+      } catch (Exception e) {
+        throw new IllegalArgumentException(
+            "Unable to attach to camera with LensFacing " + lensFacing, e);
+      }
+      return suggestedResolutionMap;
+    }
+  }
+
+  @Before
+  public void setUp() {
+    AppConfiguration appConfiguration = FakeAppConfiguration.create();
+    CameraFactory cameraFactory = appConfiguration.getCameraFactory(/*valueIfMissing=*/ null);
+    try {
+      cameraId = cameraFactory.cameraIdForLensFacing(LensFacing.BACK);
+    } catch (Exception e) {
+      throw new IllegalArgumentException(
+          "Unable to attach to camera with LensFacing " + LensFacing.BACK, e);
+    }
+    CameraX.init(ApplicationProvider.getApplicationContext(), appConfiguration);
+  }
+
+  @Test
+  public void setSingleUseCaseOnline() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces()))
+        .containsExactly(fakeUseCase.surface);
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase.deviceStateCallback, times(1)).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase.sessionStateCallback, times(1)).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase.cameraCaptureCallback, times(1)).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void setTwoUseCasesOnline() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration0 =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase0 = new TestUseCase(configuration0, cameraId);
+    FakeUseCaseConfiguration configuration1 =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase1 = new TestUseCase(configuration1, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase0);
+    useCaseAttachState.setUseCaseOnline(fakeUseCase1);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces()))
+        .containsExactly(fakeUseCase0.surface, fakeUseCase1.surface);
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase0.deviceStateCallback, times(1)).onOpened(mockCameraDevice);
+    verify(fakeUseCase1.deviceStateCallback, times(1)).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase0.sessionStateCallback, times(1)).onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase1.sessionStateCallback, times(1)).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase0.cameraCaptureCallback, times(1)).onCaptureCompleted(null);
+    verify(fakeUseCase1.cameraCaptureCallback, times(1)).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void setUseCaseActiveOnly() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseActive(fakeUseCase);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getActiveAndOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(sessionConfiguration.getSurfaces()).isEmpty();
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase.deviceStateCallback, never()).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase.sessionStateCallback, never()).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase.cameraCaptureCallback, never()).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void setUseCaseActiveAndOnline() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase);
+    useCaseAttachState.setUseCaseActive(fakeUseCase);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getActiveAndOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(DeferrableSurfaces.surfaceList(sessionConfiguration.getSurfaces()))
+        .containsExactly(fakeUseCase.surface);
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase.deviceStateCallback, times(1)).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase.sessionStateCallback, times(1)).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase.cameraCaptureCallback, times(1)).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void setUseCaseOffline() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase);
+    useCaseAttachState.setUseCaseOffline(fakeUseCase);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(sessionConfiguration.getSurfaces()).isEmpty();
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase.deviceStateCallback, never()).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase.sessionStateCallback, never()).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase.cameraCaptureCallback, never()).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void setUseCaseInactive() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase);
+    useCaseAttachState.setUseCaseActive(fakeUseCase);
+    useCaseAttachState.setUseCaseInactive(fakeUseCase);
+
+    SessionConfiguration.ValidatingBuilder builder = useCaseAttachState.getActiveAndOnlineBuilder();
+    SessionConfiguration sessionConfiguration = builder.build();
+    assertThat(sessionConfiguration.getSurfaces()).isEmpty();
+
+    sessionConfiguration.getDeviceStateCallback().onOpened(mockCameraDevice);
+    verify(fakeUseCase.deviceStateCallback, never()).onOpened(mockCameraDevice);
+
+    sessionConfiguration.getSessionStateCallback().onConfigured(mockCameraCaptureSession);
+    verify(fakeUseCase.sessionStateCallback, never()).onConfigured(mockCameraCaptureSession);
+
+    sessionConfiguration.getCameraCaptureCallback().onCaptureCompleted(null);
+    verify(fakeUseCase.cameraCaptureCallback, never()).onCaptureCompleted(null);
+  }
+
+  @Test
+  public void updateUseCase() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing0)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    useCaseAttachState.setUseCaseOnline(fakeUseCase);
+    useCaseAttachState.setUseCaseActive(fakeUseCase);
+
+    // The original template should be PREVIEW.
+    SessionConfiguration firstSessionConfiguration =
+        useCaseAttachState.getActiveAndOnlineBuilder().build();
+    assertThat(firstSessionConfiguration.getTemplateType())
+        .isEqualTo(CameraDevice.TEMPLATE_PREVIEW);
+
+    // Change the template to STILL_CAPTURE.
+    SessionConfiguration.Builder builder = new SessionConfiguration.Builder();
+    builder.setTemplateType(CameraDevice.TEMPLATE_STILL_CAPTURE);
+    fakeUseCase.attachToCamera(cameraId, builder.build());
+
+    useCaseAttachState.updateUseCase(fakeUseCase);
+
+    // The new template should be STILL_CAPTURE.
+    SessionConfiguration secondSessionConfiguration =
+        useCaseAttachState.getActiveAndOnlineBuilder().build();
+    assertThat(secondSessionConfiguration.getTemplateType())
+        .isEqualTo(CameraDevice.TEMPLATE_STILL_CAPTURE);
+  }
+
+  @Test
+  public void setUseCaseOnlineWithWrongCamera() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing1)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> useCaseAttachState.setUseCaseOnline(fakeUseCase));
+  }
+
+  @Test
+  public void setUseCaseActiveWithWrongCamera() {
+    UseCaseAttachState useCaseAttachState = new UseCaseAttachState(cameraId);
+    FakeUseCaseConfiguration configuration =
+        new FakeUseCaseConfiguration.Builder()
+            .setTargetName("UseCase")
+            .setLensFacing(cameraLensFacing1)
+            .build();
+    TestUseCase fakeUseCase = new TestUseCase(configuration, cameraId);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> useCaseAttachState.setUseCaseActive(fakeUseCase));
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupAndroidTest.java
new file mode 100644
index 0000000..2a7d81c
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupAndroidTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public final class UseCaseGroupAndroidTest {
+  private FakeUseCaseConfiguration fakeUseCaseConfiguration;
+  private FakeOtherUseCaseConfiguration fakeOtherUseCaseConfiguration;
+  private UseCaseGroup useCaseGroup;
+  private FakeUseCase fakeUseCase;
+  private FakeOtherUseCase fakeOtherUseCase;
+  private final UseCaseGroup.StateChangeListener mockListener =
+      Mockito.mock(UseCaseGroup.StateChangeListener.class);
+
+  @Before
+  public void setUp() {
+    fakeUseCaseConfiguration =
+        new FakeUseCaseConfiguration.Builder().setTargetName("fakeUseCaseConfiguration").build();
+    fakeOtherUseCaseConfiguration =
+        new FakeOtherUseCaseConfiguration.Builder()
+            .setTargetName("fakeOtherUseCaseConfiguration")
+            .build();
+    useCaseGroup = new UseCaseGroup();
+    fakeUseCase = new FakeUseCase(fakeUseCaseConfiguration);
+    fakeOtherUseCase = new FakeOtherUseCase(fakeOtherUseCaseConfiguration);
+  }
+
+  @Test
+  public void groupStartsEmpty() {
+    assertThat(useCaseGroup.getUseCases()).isEmpty();
+  }
+
+  @Test
+  public void newUseCaseIsAdded_whenNoneExistsInGroup() {
+    assertThat(useCaseGroup.addUseCase(fakeUseCase)).isTrue();
+    assertThat(useCaseGroup.getUseCases()).containsExactly(fakeUseCase);
+  }
+
+  @Test
+  public void multipleUseCases_canBeAdded() {
+    assertThat(useCaseGroup.addUseCase(fakeUseCase)).isTrue();
+    assertThat(useCaseGroup.addUseCase(fakeOtherUseCase)).isTrue();
+
+    assertThat(useCaseGroup.getUseCases()).containsExactly(fakeUseCase, fakeOtherUseCase);
+  }
+
+  @Test
+  public void groupBecomesEmpty_afterGroupIsCleared() {
+    useCaseGroup.addUseCase(fakeUseCase);
+    useCaseGroup.clear();
+
+    assertThat(useCaseGroup.getUseCases()).isEmpty();
+  }
+
+  @Test
+  public void useCaseIsCleared_afterGroupIsCleared() {
+    useCaseGroup.addUseCase(fakeUseCase);
+    assertThat(fakeUseCase.isCleared()).isFalse();
+
+    useCaseGroup.clear();
+
+    assertThat(fakeUseCase.isCleared()).isTrue();
+  }
+
+  @Test
+  public void useCaseRemoved_afterRemovedCalled() {
+    useCaseGroup.addUseCase(fakeUseCase);
+
+    useCaseGroup.removeUseCase(fakeUseCase);
+
+    assertThat(useCaseGroup.getUseCases()).isEmpty();
+  }
+
+  @Test
+  public void listenerOnGroupActive_ifUseCaseGroupStarted() {
+    useCaseGroup.setListener(mockListener);
+    useCaseGroup.start();
+
+    verify(mockListener, times(1)).onGroupActive(useCaseGroup);
+  }
+
+  @Test
+  public void listenerOnGroupInactive_ifUseCaseGroupStopped() {
+    useCaseGroup.setListener(mockListener);
+    useCaseGroup.stop();
+
+    verify(mockListener, times(1)).onGroupInactive(useCaseGroup);
+  }
+
+  @Test
+  public void setListener_replacesPreviousListener() {
+    useCaseGroup.setListener(mockListener);
+    useCaseGroup.setListener(null);
+
+    useCaseGroup.start();
+    verify(mockListener, never()).onGroupActive(useCaseGroup);
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupLifecycleControllerAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupLifecycleControllerAndroidTest.java
new file mode 100644
index 0000000..8feebac
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupLifecycleControllerAndroidTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import androidx.camera.testing.fakes.FakeLifecycleOwner;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+@RunWith(AndroidJUnit4.class)
+public class UseCaseGroupLifecycleControllerAndroidTest {
+  private UseCaseGroupLifecycleController useCaseGroupLifecycleController;
+  private FakeLifecycleOwner lifecycleOwner;
+  private final UseCaseGroup.StateChangeListener mockListener =
+      Mockito.mock(UseCaseGroup.StateChangeListener.class);
+
+  @Before
+  public void setUp() {
+    lifecycleOwner = new FakeLifecycleOwner();
+  }
+
+  @Test
+  public void groupCanBeMadeObserverOfLifecycle() {
+    assertThat(lifecycleOwner.getObserverCount()).isEqualTo(0);
+
+    useCaseGroupLifecycleController =
+        new UseCaseGroupLifecycleController(lifecycleOwner.getLifecycle(), new UseCaseGroup());
+
+    assertThat(lifecycleOwner.getObserverCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void groupCanStopObservingALifeCycle() {
+    useCaseGroupLifecycleController =
+        new UseCaseGroupLifecycleController(lifecycleOwner.getLifecycle(), new UseCaseGroup());
+    assertThat(lifecycleOwner.getObserverCount()).isEqualTo(1);
+
+    useCaseGroupLifecycleController.release();
+
+    assertThat(lifecycleOwner.getObserverCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void groupCanBeReleasedMultipleTimes() {
+    useCaseGroupLifecycleController =
+        new UseCaseGroupLifecycleController(lifecycleOwner.getLifecycle(), new UseCaseGroup());
+
+    useCaseGroupLifecycleController.release();
+    useCaseGroupLifecycleController.release();
+  }
+
+  @Test
+  public void lifecycleStart_triggersOnActive() {
+    useCaseGroupLifecycleController =
+        new UseCaseGroupLifecycleController(lifecycleOwner.getLifecycle(), new UseCaseGroup());
+    useCaseGroupLifecycleController.getUseCaseGroup().setListener(mockListener);
+
+    lifecycleOwner.start();
+
+    verify(mockListener, times(1))
+        .onGroupActive(useCaseGroupLifecycleController.getUseCaseGroup());
+  }
+
+  @Test
+  public void lifecycleStop_triggersOnInactive() {
+    useCaseGroupLifecycleController =
+        new UseCaseGroupLifecycleController(lifecycleOwner.getLifecycle(), new UseCaseGroup());
+    useCaseGroupLifecycleController.getUseCaseGroup().setListener(mockListener);
+    lifecycleOwner.start();
+
+    lifecycleOwner.stop();
+
+    verify(mockListener, times(1))
+        .onGroupInactive(useCaseGroupLifecycleController.getUseCaseGroup());
+  }
+}
diff --git a/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupRepositoryAndroidTest.java b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupRepositoryAndroidTest.java
new file mode 100644
index 0000000..59e88df
--- /dev/null
+++ b/camera/core/src/androidTest/java/androidx/camera/core/UseCaseGroupRepositoryAndroidTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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 androidx.camera.core;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.arch.lifecycle.LifecycleOwner;
+import androidx.camera.testing.fakes.FakeLifecycleOwner;
+import androidx.test.runner.AndroidJUnit4;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class UseCaseGroupRepositoryAndroidTest {
+
+  private FakeLifecycleOwner lifecycle;
+  private UseCaseGroupRepository repository;
+  private Map<LifecycleOwner, UseCaseGroupLifecycleController> useCasesMap;
+
+  @Before
+  public void setUp() {
+    lifecycle = new FakeLifecycleOwner();
+    repository = new UseCaseGroupRepository();
+    useCasesMap = repository.getUseCasesMap();
+  }
+
+  @Test
+  public void repositoryStartsEmpty() {
+    assertThat(useCasesMap).isEmpty();
+  }
+
+  @Test
+  public void newUseCaseGroupIsCreated_whenNoGroupExistsForLifecycleInRepository() {
+    UseCaseGroupLifecycleController group = repository.getOrCreateUseCaseGroup(lifecycle);
+
+    assertThat(useCasesMap).containsExactly(lifecycle, group);
+  }
+
+  @Test
+  public void existingUseCaseGroupIsReturned_whenGroupExistsForLifecycleInRepository() {
+    UseCaseGroupLifecycleController firstGroup = repository.getOrCreateUseCaseGroup(lifecycle);
+    UseCaseGroupLifecycleController secondGroup = repository.getOrCreateUseCaseGroup(lifecycle);
+
+    assertThat(firstGroup).isSameAs(secondGroup);
+    assertThat(useCasesMap).containsExactly(lifecycle, firstGroup);
+  }
+
+  @Test
+  public void differentUseCaseGroupsAreCreated_forDifferentLifecycles() {
+    UseCaseGroupLifecycleController firstGroup = repository.getOrCreateUseCaseGroup(lifecycle);
+    FakeLifecycleOwner secondLifecycle = new FakeLifecycleOwner();
+    UseCaseGroupLifecycleController secondGroup =
+        repository.getOrCreateUseCaseGroup(secondLifecycle);
+
+    assertThat(useCasesMap).containsExactly(lifecycle, firstGroup, secondLifecycle, secondGroup);
+  }
+
+  @Test
+  public void useCaseGroupObservesLifecycle() {
+    repository.getOrCreateUseCaseGroup(lifecycle);
+
+    // One observer is the use case group. The other observer removes the use case from the
+    // repository when the lifecycle is destroyed.
+    assertThat(lifecycle.getObserverCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void useCaseGroupIsRemovedFromRepository_whenLifecycleIsDestroyed() {
+    repository.getOrCreateUseCaseGroup(lifecycle);
+    lifecycle.destroy();
+
+    assertThat(useCasesMap).isEmpty();
+  }
+
+  @Test
+  public void useCaseIsCleared_whenLifecycleIsDestroyed() {
+    UseCaseGroupLifecycleController group = repository.getOrCreateUseCaseGroup(lifecycle);
+    FakeUseCase useCase = new FakeUseCase();
+    group.getUseCaseGroup().addUseCase(useCase);
+
+    assertThat(useCase.isCleared()).isFalse();
+
+    lifecycle.destroy();
+
+    assertThat(useCase.isCleared()).isTrue();
+  }
+
+  @Test
+  public void exception_whenCreatingWithDestroyedLifecycle() {
+    lifecycle.destroy();
+
+    assertThrows(
+        IllegalArgumentException.class, () -> repository.getOrCreateUseCaseGroup(lifecycle));
+  }
+}