Merge "startop: add compiler type support to host and device switch."
diff --git a/Android.bp b/Android.bp
index 121decb..ee3cb8f 100644
--- a/Android.bp
+++ b/Android.bp
@@ -234,6 +234,11 @@
             "wifi/java",
         ],
     },
+
+    required: [
+        // TODO: remove gps_debug when the build system propagates "required" properly.
+        "gps_debug.conf",
+    ],
 }
 
 // Collection of classes that are generated from non-Java files that are not listed in
@@ -307,11 +312,6 @@
 
     static_libs: ["framework-internal-utils"],
 
-    required: [
-        // TODO: remove gps_debug when the build system propagates "required" properly.
-        "gps_debug.conf",
-    ],
-
     dxflags: [
         "--core-library",
         "--multi-dex",
diff --git a/core/java/android/app/AppOpsManager.java b/core/java/android/app/AppOpsManager.java
index 1f283ea..bb87d96 100644
--- a/core/java/android/app/AppOpsManager.java
+++ b/core/java/android/app/AppOpsManager.java
@@ -5845,39 +5845,42 @@
                     ActivityThread.currentOpPackageName())) {
                 // This app is noting an app-op for itself. Deliver immediately.
                 sNotedAppOpsCollector.onSelfNoted(new SyncNotedAppOp(code));
-            } else if (binderUid != null && binderUid == uid) {
-                // We are inside of a two-way binder call. Delivered to caller via
-                // {@link #prefixParcelWithAppOpsIfNeeded}
-                long[] appOpsNotedInThisBinderTransaction;
 
-                appOpsNotedInThisBinderTransaction = sAppOpsNotedInThisBinderTransaction.get();
-                if (appOpsNotedInThisBinderTransaction == null) {
-                    appOpsNotedInThisBinderTransaction = new long[2];
-                    sAppOpsNotedInThisBinderTransaction.set(appOpsNotedInThisBinderTransaction);
-                }
+                return;
+            }
+        }
 
-                if (code < 64) {
-                    appOpsNotedInThisBinderTransaction[0] |= 1L << code;
-                } else {
-                    appOpsNotedInThisBinderTransaction[1] |= 1L << (code - 64);
-                }
+        if (binderUid != null && binderUid == uid) {
+            // If this is inside of a two-way binder call: Delivered to caller via
+            // {@link #prefixParcelWithAppOpsIfNeeded}
+            long[] appOpsNotedInThisBinderTransaction;
+
+            appOpsNotedInThisBinderTransaction = sAppOpsNotedInThisBinderTransaction.get();
+            if (appOpsNotedInThisBinderTransaction == null) {
+                appOpsNotedInThisBinderTransaction = new long[2];
+                sAppOpsNotedInThisBinderTransaction.set(appOpsNotedInThisBinderTransaction);
+            }
+
+            if (code < 64) {
+                appOpsNotedInThisBinderTransaction[0] |= 1L << code;
             } else {
-                // We cannot deliver the note synchronous. Hence send it to the system server to
-                // notify the noted process.
-                if (message == null) {
-                    // Default message is a stack trace
-                    message = getFormattedStackTrace();
-                }
+                appOpsNotedInThisBinderTransaction[1] |= 1L << (code - 64);
+            }
+        } else {
+            // Cannot deliver the note synchronous: Hence send it to the system server to
+            // notify the noted process.
+            if (message == null) {
+                // Default message is a stack trace
+                message = getFormattedStackTrace();
+            }
 
-                long token = Binder.clearCallingIdentity();
-                try {
-                    mService.noteAsyncOp(mContext.getOpPackageName(), uid, packageName, code,
-                            message);
-                } catch (RemoteException e) {
-                    e.rethrowFromSystemServer();
-                } finally {
-                    Binder.restoreCallingIdentity(token);
-                }
+            long token = Binder.clearCallingIdentity();
+            try {
+                mService.noteAsyncOp(mContext.getOpPackageName(), uid, packageName, code, message);
+            } catch (RemoteException e) {
+                e.rethrowFromSystemServer();
+            } finally {
+                Binder.restoreCallingIdentity(token);
             }
         }
     }
diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java
index faeecda..de77aaa 100644
--- a/core/java/com/android/internal/app/ChooserActivity.java
+++ b/core/java/com/android/internal/app/ChooserActivity.java
@@ -493,6 +493,9 @@
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         final long intentReceivedTime = System.currentTimeMillis();
+        // This is the only place this value is being set. Effectively final.
+        mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable();
+
         mIsSuccessfullySelected = false;
         Intent intent = getIntent();
         Parcelable targetParcelable = intent.getParcelableExtra(Intent.EXTRA_INTENT);
@@ -619,9 +622,6 @@
                 .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, target.getType())
                 .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost));
 
-        // This is the only place this value is being set. Effectively final.
-        mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable();
-
         AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled();
         if (appPredictor != null) {
             mDirectShareAppTargetCache = new HashMap<>();
diff --git a/core/java/com/android/internal/os/ZygoteInit.java b/core/java/com/android/internal/os/ZygoteInit.java
index b5d4945..31995f7 100644
--- a/core/java/com/android/internal/os/ZygoteInit.java
+++ b/core/java/com/android/internal/os/ZygoteInit.java
@@ -460,7 +460,7 @@
         ZygoteHooks.gcAndFinalize();
     }
 
-    private static boolean profileSystemServer() {
+    private static boolean shouldProfileSystemServer() {
         boolean defaultValue = SystemProperties.getBoolean("dalvik.vm.profilesystemserver",
                 /*default=*/ false);
         // Can't use DeviceConfig since it's not initialized at this point.
@@ -492,7 +492,7 @@
             }
             // Capturing profiles is only supported for debug or eng builds since selinux normally
             // prevents it.
-            if (profileSystemServer() && (Build.IS_USERDEBUG || Build.IS_ENG)) {
+            if (shouldProfileSystemServer() && (Build.IS_USERDEBUG || Build.IS_ENG)) {
                 try {
                     Log.d(TAG, "Preparing system server profile");
                     prepareSystemServerProfile(systemServerClasspath);
@@ -765,8 +765,7 @@
             Zygote.applyDebuggerSystemProperty(parsedArgs);
             Zygote.applyInvokeWithSystemProperty(parsedArgs);
 
-            if (profileSystemServer()) {
-
+            if (shouldProfileSystemServer()) {
                 parsedArgs.mRuntimeFlags |= Zygote.PROFILE_SYSTEM_SERVER;
             }
 
diff --git a/core/java/com/android/internal/util/ScreenshotHelper.java b/core/java/com/android/internal/util/ScreenshotHelper.java
index 7fd94c6..cac691c 100644
--- a/core/java/com/android/internal/util/ScreenshotHelper.java
+++ b/core/java/com/android/internal/util/ScreenshotHelper.java
@@ -1,6 +1,7 @@
 package com.android.internal.util;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -13,6 +14,8 @@
 import android.os.UserHandle;
 import android.util.Log;
 
+import java.util.function.Consumer;
+
 public class ScreenshotHelper {
     private static final String TAG = "ScreenshotHelper";
 
@@ -34,17 +37,58 @@
     }
 
     /**
-     * Request a screenshot be taken.
+     * Request a screenshot be taken with a specific timeout.
      *
-     * @param screenshotType The type of screenshot, for example either
-     *                       {@link android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN}
-     *                       or {@link android.view.WindowManager.TAKE_SCREENSHOT_SELECTED_REGION}
-     * @param hasStatus {@code true} if the status bar is currently showing. {@code false} if not.
-     * @param hasNav {@code true} if the navigation bar is currently showing. {@code false} if not.
-     * @param handler A handler used in case the screenshot times out
+     * Added to support reducing unit test duration; the method variant without a timeout argument
+     * is recommended for general use.
+     *
+     * @param screenshotType     The type of screenshot, for example either
+     *                           {@link android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN}
+     *                           or
+     *                           {@link android.view.WindowManager.TAKE_SCREENSHOT_SELECTED_REGION}
+     * @param hasStatus          {@code true} if the status bar is currently showing. {@code false}
+     *                           if
+     *                           not.
+     * @param hasNav             {@code true} if the navigation bar is currently showing. {@code
+     *                           false}
+     *                           if not.
+     * @param handler            A handler used in case the screenshot times out
+     * @param completionConsumer Consumes `false` if a screenshot was not taken, and `true` if the
+     *                           screenshot was taken.
      */
     public void takeScreenshot(final int screenshotType, final boolean hasStatus,
-            final boolean hasNav, @NonNull Handler handler) {
+            final boolean hasNav, @NonNull Handler handler,
+            @Nullable Consumer<Boolean> completionConsumer) {
+        takeScreenshot(screenshotType, hasStatus, hasNav, SCREENSHOT_TIMEOUT_MS, handler,
+                completionConsumer);
+    }
+
+    /**
+     * Request a screenshot be taken.
+     *
+     * Added to support reducing unit test duration; the method variant without a timeout argument
+     * is recommended for general use.
+     *
+     * @param screenshotType     The type of screenshot, for example either
+     *                           {@link android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN}
+     *                           or
+     *                           {@link android.view.WindowManager.TAKE_SCREENSHOT_SELECTED_REGION}
+     * @param hasStatus          {@code true} if the status bar is currently showing. {@code false}
+     *                           if
+     *                           not.
+     * @param hasNav             {@code true} if the navigation bar is currently showing. {@code
+     *                           false}
+     *                           if not.
+     * @param timeoutMs          If the screenshot hasn't been completed within this time period,
+     *                           the screenshot attempt will be cancelled and `completionConsumer`
+     *                           will be run.
+     * @param handler            A handler used in case the screenshot times out
+     * @param completionConsumer Consumes `false` if a screenshot was not taken, and `true` if the
+     *                           screenshot was taken.
+     */
+    public void takeScreenshot(final int screenshotType, final boolean hasStatus,
+            final boolean hasNav, long timeoutMs, @NonNull Handler handler,
+            @Nullable Consumer<Boolean> completionConsumer) {
         synchronized (mScreenshotLock) {
             if (mScreenshotConnection != null) {
                 return;
@@ -54,7 +98,8 @@
             final Intent serviceIntent = new Intent();
 
             final Runnable mScreenshotTimeout = new Runnable() {
-                @Override public void run() {
+                @Override
+                public void run() {
                     synchronized (mScreenshotLock) {
                         if (mScreenshotConnection != null) {
                             mContext.unbindService(mScreenshotConnection);
@@ -62,6 +107,9 @@
                             notifyScreenshotError();
                         }
                     }
+                    if (completionConsumer != null) {
+                        completionConsumer.accept(false);
+                    }
                 }
             };
 
@@ -86,15 +134,22 @@
                                         handler.removeCallbacks(mScreenshotTimeout);
                                     }
                                 }
+                                if (completionConsumer != null) {
+                                    completionConsumer.accept(true);
+                                }
                             }
                         };
                         msg.replyTo = new Messenger(h);
-                        msg.arg1 = hasStatus ? 1: 0;
-                        msg.arg2 = hasNav ? 1: 0;
+                        msg.arg1 = hasStatus ? 1 : 0;
+                        msg.arg2 = hasNav ? 1 : 0;
+
                         try {
                             messenger.send(msg);
                         } catch (RemoteException e) {
                             Log.e(TAG, "Couldn't take screenshot: " + e);
+                            if (completionConsumer != null) {
+                                completionConsumer.accept(false);
+                            }
                         }
                     }
                 }
@@ -115,7 +170,7 @@
                     Context.BIND_AUTO_CREATE | Context.BIND_FOREGROUND_SERVICE_WHILE_AWAKE,
                     UserHandle.CURRENT)) {
                 mScreenshotConnection = conn;
-                handler.postDelayed(mScreenshotTimeout, SCREENSHOT_TIMEOUT_MS);
+                handler.postDelayed(mScreenshotTimeout, timeoutMs);
             }
         }
     }
diff --git a/core/jni/android_os_Debug.cpp b/core/jni/android_os_Debug.cpp
index 13e1dfa..cf8df28 100644
--- a/core/jni/android_os_Debug.cpp
+++ b/core/jni/android_os_Debug.cpp
@@ -564,11 +564,13 @@
 
     // Read system memory info including ZRAM. The values are stored in the vector
     // in the same order as MEMINFO_* enum
-    std::vector<uint64_t> mem(MEMINFO_COUNT);
-    std::vector<std::string> tags(::android::meminfo::SysMemInfo::kDefaultSysMemInfoTags);
+    std::vector<std::string_view> tags(
+        ::android::meminfo::SysMemInfo::kDefaultSysMemInfoTags.begin(),
+        ::android::meminfo::SysMemInfo::kDefaultSysMemInfoTags.end());
     tags.insert(tags.begin() + MEMINFO_ZRAM_TOTAL, "Zram:");
+    std::vector<uint64_t> mem(tags.size());
     ::android::meminfo::SysMemInfo smi;
-    if (!smi.ReadMemInfo(tags, &mem)) {
+    if (!smi.ReadMemInfo(tags.size(), tags.data(), mem.data())) {
         jniThrowRuntimeException(env, "SysMemInfo read failed");
         return;
     }
diff --git a/core/jni/android_util_Process.cpp b/core/jni/android_util_Process.cpp
index 4cb2d15..8aa6f86 100644
--- a/core/jni/android_util_Process.cpp
+++ b/core/jni/android_util_Process.cpp
@@ -31,6 +31,7 @@
 #include <android-base/unique_fd.h>
 
 #include <algorithm>
+#include <array>
 #include <limits>
 #include <memory>
 #include <string>
@@ -630,14 +631,16 @@
 
 static jlong android_os_Process_getFreeMemory(JNIEnv* env, jobject clazz)
 {
-    static const std::vector<std::string> memFreeTags = {
+    std::array<std::string_view, 2> memFreeTags = {
         ::android::meminfo::SysMemInfo::kMemFree,
         ::android::meminfo::SysMemInfo::kMemCached,
     };
     std::vector<uint64_t> mem(memFreeTags.size());
     ::android::meminfo::SysMemInfo smi;
 
-    if (!smi.ReadMemInfo(memFreeTags, &mem)) {
+    if (!smi.ReadMemInfo(memFreeTags.size(),
+                         memFreeTags.data(),
+                         mem.data())) {
         jniThrowRuntimeException(env, "SysMemInfo read failed to get Free Memory");
         return -1L;
     }
diff --git a/core/proto/Android.bp b/core/proto/Android.bp
index 3b891d6..e199dab 100644
--- a/core/proto/Android.bp
+++ b/core/proto/Android.bp
@@ -28,3 +28,13 @@
         "android/bluetooth/smp/enums.proto",
     ],
 }
+
+java_library_host {
+    name: "windowmanager-log-proto",
+    srcs: [
+        "android/server/windowmanagerlog.proto"
+    ],
+    proto: {
+        type: "full",
+    },
+}
diff --git a/core/proto/android/server/windowmanagerlog.proto b/core/proto/android/server/windowmanagerlog.proto
new file mode 100644
index 0000000..5bee1bd
--- /dev/null
+++ b/core/proto/android/server/windowmanagerlog.proto
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2017 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.
+ */
+
+syntax = "proto2";
+
+package com.android.server.wm;
+
+option java_multiple_files = true;
+
+/* represents a single log entry */
+message ProtoLogMessage {
+    /* log statement identifier, created from message string and log level. */
+    optional fixed32 message_hash = 1;
+    /* log time, relative to the elapsed system time clock. */
+    optional fixed64 elapsed_realtime_nanos = 2;
+    /* string parameters passed to the log call. */
+    repeated string str_params = 3;
+    /* integer parameters passed to the log call. */
+    repeated sint64 sint64_params = 4 [packed=true];
+    /* floating point parameters passed to the log call. */
+    repeated double double_params = 5 [packed=true];
+    /* boolean parameters passed to the log call. */
+    repeated bool boolean_params = 6 [packed=true];
+}
+
+/* represents a log file containing window manager log entries.
+   Encoded, it should start with 0x9 0x57 0x49 0x4e 0x44 0x4f 0x4c 0x4f 0x47 (.WINDOLOG), such
+   that they can be easily identified. */
+message WindowManagerLogFileProto {
+    /* constant; MAGIC_NUMBER = (long) MAGIC_NUMBER_H << 32 | MagicNumber.MAGIC_NUMBER_L
+       (this is needed because enums have to be 32 bits and there's no nice way to put 64bit
+        constants into .proto files. */
+    enum MagicNumber {
+        INVALID = 0;
+        MAGIC_NUMBER_L = 0x444e4957; /* WIND (little-endian ASCII) */
+        MAGIC_NUMBER_H = 0x474f4c4f; /* OLOG (little-endian ASCII) */
+    }
+
+    /* the magic number header */
+    optional fixed64 magic_number = 1;
+    /* log proto version. */
+    optional string version = 2;
+    /* offset between real-time clock and elapsed system time clock in miliseconds.
+       Calculated as: (System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() / 1000000) */
+    optional fixed64 realTimeToElapsedTimeOffsetMillis = 3;
+    /* log entries */
+    repeated ProtoLogMessage log = 4;
+}
diff --git a/core/proto/android/stats/docsui/docsui_enums.proto b/core/proto/android/stats/docsui/docsui_enums.proto
index 655b5e3..f648912 100644
--- a/core/proto/android/stats/docsui/docsui_enums.proto
+++ b/core/proto/android/stats/docsui/docsui_enums.proto
@@ -184,6 +184,8 @@
     TYPE_CHIP_DOCS = 4;
     TYPE_SEARCH_HISTORY = 5;
     TYPE_SEARCH_STRING = 6;
+    TYPE_CHIP_LARGE_FILES = 7;
+    TYPE_CHIP_FROM_THIS_WEEK = 8;
 }
 
 enum SearchMode {
diff --git a/core/tests/screenshothelpertests/Android.bp b/core/tests/screenshothelpertests/Android.bp
new file mode 100644
index 0000000..3d54b68
--- /dev/null
+++ b/core/tests/screenshothelpertests/Android.bp
@@ -0,0 +1,28 @@
+android_test {
+    name: "ScreenshotHelperTests",
+
+    srcs: [
+        "src/**/*.java",
+    ],
+    
+    static_libs: [
+        "frameworks-base-testutils",
+        "androidx.test.runner",
+        "androidx.test.rules",
+        "androidx.test.ext.junit",
+        "mockito-target-minus-junit4",
+        "platform-test-annotations",
+    ],
+
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+        "android.test.mock",
+    ],
+
+    platform_apis: true,
+    test_suites: ["device-tests"],
+
+    certificate: "platform",
+}
+
diff --git a/core/tests/screenshothelpertests/AndroidManifest.xml b/core/tests/screenshothelpertests/AndroidManifest.xml
new file mode 100644
index 0000000..2e12ef4d
--- /dev/null
+++ b/core/tests/screenshothelpertests/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?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"
+          android:installLocation="internalOnly"
+          package="com.android.internal.util"
+          android:sharedUserId="android.uid.systemui" >
+
+    <application >
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+            android:targetPackage="com.android.internal.util"
+            android:label="Screenshot Helper Tests" />
+
+            <protected-broadcast android:name="android.intent.action.USER_PRESENT" />
+
+</manifest>
diff --git a/core/tests/screenshothelpertests/src/com/android/internal/util/ScreenshotHelperTest.java b/core/tests/screenshothelpertests/src/com/android/internal/util/ScreenshotHelperTest.java
new file mode 100644
index 0000000..8483645
--- /dev/null
+++ b/core/tests/screenshothelpertests/src/com/android/internal/util/ScreenshotHelperTest.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.util;
+
+import static android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN;
+import static android.view.WindowManager.TAKE_SCREENSHOT_SELECTED_REGION;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.fail;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public final class ScreenshotHelperTest {
+    private Context mContext;
+    private ScreenshotHelper mScreenshotHelper;
+    private Handler mHandler;
+
+
+    @Before
+    public void setUp() {
+        // `ScreenshotHelper.notifyScreenshotError()` calls `Context.sendBroadcastAsUser()` and
+        // `Context.bindServiceAsUser`.
+        //
+        // This raises a `SecurityException` if the device is locked. Calling either `Context`
+        // method results in a broadcast of `android.intent.action. USER_PRESENT`. Only the system
+        // process is allowed to broadcast that `Intent`.
+        mContext = Mockito.spy(Context.class);
+        Mockito.doNothing().when(mContext).sendBroadcastAsUser(any(), any());
+        Mockito.doReturn(true).when(mContext).bindServiceAsUser(any(), any(), anyInt(), any());
+
+        mHandler = new Handler(Looper.getMainLooper());
+        mScreenshotHelper = new ScreenshotHelper(mContext);
+    }
+
+    @Test
+    public void testFullscreenScreenshot() {
+        mScreenshotHelper.takeScreenshot(TAKE_SCREENSHOT_FULLSCREEN, false, false, mHandler, null);
+    }
+
+    @Test
+    public void testSelectedRegionScreenshot() {
+        mScreenshotHelper.takeScreenshot(TAKE_SCREENSHOT_SELECTED_REGION, false, false, mHandler,
+                null);
+    }
+
+    @Test
+    public void testScreenshotTimesOut() {
+        long timeoutMs = 10;
+
+        CountDownLatch lock = new CountDownLatch(1);
+        mScreenshotHelper.takeScreenshot(TAKE_SCREENSHOT_FULLSCREEN, false, false, timeoutMs,
+                mHandler,
+                worked -> {
+                    assertFalse(worked);
+                    lock.countDown();
+                });
+
+        try {
+            // Add tolerance for delay to prevent flakes.
+            long awaitDurationMs = timeoutMs + 100;
+            if (!lock.await(awaitDurationMs, TimeUnit.MILLISECONDS)) {
+                fail("lock never freed");
+            }
+        } catch (InterruptedException e) {
+            fail("lock interrupted");
+        }
+    }
+}
diff --git a/packages/BackupEncryption/Android.bp b/packages/BackupEncryption/Android.bp
index 50dbcdb..9bcd677 100644
--- a/packages/BackupEncryption/Android.bp
+++ b/packages/BackupEncryption/Android.bp
@@ -17,8 +17,15 @@
 android_app {
     name: "BackupEncryption",
     srcs: ["src/**/*.java"],
+    libs: ["backup-encryption-protos"],
     optimize: { enabled: false },
     platform_apis: true,
     certificate: "platform",
     privileged: true,
-}
\ No newline at end of file
+}
+
+java_library {
+    name: "backup-encryption-protos",
+    proto: { type: "nano" },
+    srcs: ["proto/**/*.proto"],
+}
diff --git a/packages/BackupEncryption/proto/wrapped_key.proto b/packages/BackupEncryption/proto/wrapped_key.proto
new file mode 100644
index 0000000..817b7b4
--- /dev/null
+++ b/packages/BackupEncryption/proto/wrapped_key.proto
@@ -0,0 +1,52 @@
+syntax = "proto2";
+
+package android_backup_crypto;
+
+option java_package = "com.android.server.backup.encryption.protos";
+option java_outer_classname = "WrappedKeyProto";
+
+// Metadata associated with a tertiary key.
+message KeyMetadata {
+  // Type of Cipher algorithm the key is used for.
+  enum Type {
+    UNKNOWN = 0;
+    // No padding. Uses 12-byte nonce. Tag length 16 bytes.
+    AES_256_GCM = 1;
+  }
+
+  // What kind of Cipher algorithm the key is used for. We assume at the moment
+  // that this will always be AES_256_GCM and throw if this is not the case.
+  // Provided here for forwards compatibility in case at some point we need to
+  // change Cipher algorithm.
+  optional Type type = 1;
+}
+
+// An encrypted tertiary key.
+message WrappedKey {
+  // The Cipher with which the key was encrypted.
+  enum WrapAlgorithm {
+    UNKNOWN = 0;
+    // No padding. Uses 16-byte nonce (see nonce field). Tag length 16 bytes.
+    // The nonce is 16-bytes as this is wrapped with a key in AndroidKeyStore.
+    // AndroidKeyStore requires that it generates the IV, and it generates a
+    // 16-byte IV for you. You CANNOT provide your own IV.
+    AES_256_GCM = 1;
+  }
+
+  // Cipher algorithm used to wrap the key. We assume at the moment that this
+  // is always AES_256_GC and throw if this is not the case. Provided here for
+  // forwards compatibility if at some point we need to change Cipher algorithm.
+  optional WrapAlgorithm wrap_algorithm = 1;
+
+  // The nonce used to initialize the Cipher in AES/256/GCM mode.
+  optional bytes nonce = 2;
+
+  // The encrypted bytes of the key material.
+  optional bytes key = 3;
+
+  // Associated key metadata.
+  optional KeyMetadata metadata = 4;
+
+  // Deprecated field; Do not use
+  reserved 5;
+}
diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java
new file mode 100644
index 0000000..a043c1f
--- /dev/null
+++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.backup.encryption.keys;
+
+import com.android.server.backup.encryption.protos.nano.WrappedKeyProto;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+/** Utility functions for wrapping and unwrapping tertiary keys. */
+public class KeyWrapUtils {
+    private static final String AES_GCM_MODE = "AES/GCM/NoPadding";
+    private static final int GCM_TAG_LENGTH_BYTES = 16;
+    private static final int BITS_PER_BYTE = 8;
+    private static final int GCM_TAG_LENGTH_BITS = GCM_TAG_LENGTH_BYTES * BITS_PER_BYTE;
+    private static final String KEY_ALGORITHM = "AES";
+
+    /**
+     * Uses the secondary key to unwrap the wrapped tertiary key.
+     *
+     * @param secondaryKey The secondary key used to wrap the tertiary key.
+     * @param wrappedKey The wrapped tertiary key.
+     * @return The unwrapped tertiary key.
+     * @throws InvalidKeyException if the provided secondary key cannot unwrap the tertiary key.
+     */
+    public static SecretKey unwrap(SecretKey secondaryKey, WrappedKeyProto.WrappedKey wrappedKey)
+            throws InvalidKeyException, NoSuchAlgorithmException,
+                    InvalidAlgorithmParameterException, NoSuchPaddingException {
+        if (wrappedKey.wrapAlgorithm != WrappedKeyProto.WrappedKey.AES_256_GCM) {
+            throw new InvalidKeyException(
+                    String.format(
+                            Locale.US,
+                            "Could not unwrap key wrapped with %s algorithm",
+                            wrappedKey.wrapAlgorithm));
+        }
+
+        if (wrappedKey.metadata == null) {
+            throw new InvalidKeyException("Metadata missing from wrapped tertiary key.");
+        }
+
+        if (wrappedKey.metadata.type != WrappedKeyProto.KeyMetadata.AES_256_GCM) {
+            throw new InvalidKeyException(
+                    String.format(
+                            Locale.US,
+                            "Wrapped key was unexpected %s algorithm. Only support"
+                                + " AES/GCM/NoPadding.",
+                            wrappedKey.metadata.type));
+        }
+
+        Cipher cipher = getCipher();
+
+        cipher.init(
+                Cipher.UNWRAP_MODE,
+                secondaryKey,
+                new GCMParameterSpec(GCM_TAG_LENGTH_BITS, wrappedKey.nonce));
+
+        return (SecretKey) cipher.unwrap(wrappedKey.key, KEY_ALGORITHM, Cipher.SECRET_KEY);
+    }
+
+    /**
+     * Wraps the tertiary key with the secondary key.
+     *
+     * @param secondaryKey The secondary key to use for wrapping.
+     * @param tertiaryKey The key to wrap.
+     * @return The wrapped key.
+     * @throws InvalidKeyException if the key is not good for wrapping.
+     * @throws IllegalBlockSizeException if there is an issue wrapping.
+     */
+    public static WrappedKeyProto.WrappedKey wrap(SecretKey secondaryKey, SecretKey tertiaryKey)
+            throws InvalidKeyException, IllegalBlockSizeException, NoSuchAlgorithmException,
+                    NoSuchPaddingException {
+        Cipher cipher = getCipher();
+        cipher.init(Cipher.WRAP_MODE, secondaryKey);
+
+        WrappedKeyProto.WrappedKey wrappedKey = new WrappedKeyProto.WrappedKey();
+        wrappedKey.key = cipher.wrap(tertiaryKey);
+        wrappedKey.nonce = cipher.getIV();
+        wrappedKey.wrapAlgorithm = WrappedKeyProto.WrappedKey.AES_256_GCM;
+        wrappedKey.metadata = new WrappedKeyProto.KeyMetadata();
+        wrappedKey.metadata.type = WrappedKeyProto.KeyMetadata.AES_256_GCM;
+        return wrappedKey;
+    }
+
+    /**
+     * Rewraps a tertiary key with a new secondary key.
+     *
+     * @param oldSecondaryKey The old secondary key, used to unwrap the tertiary key.
+     * @param newSecondaryKey The new secondary key, used to rewrap the tertiary key.
+     * @param tertiaryKey The tertiary key, wrapped by {@code oldSecondaryKey}.
+     * @return The tertiary key, wrapped by {@code newSecondaryKey}.
+     * @throws InvalidKeyException if the key is not good for wrapping or unwrapping.
+     * @throws IllegalBlockSizeException if there is an issue wrapping.
+     */
+    public static WrappedKeyProto.WrappedKey rewrap(
+            SecretKey oldSecondaryKey,
+            SecretKey newSecondaryKey,
+            WrappedKeyProto.WrappedKey tertiaryKey)
+            throws InvalidKeyException, IllegalBlockSizeException,
+                    InvalidAlgorithmParameterException, NoSuchAlgorithmException,
+                    NoSuchPaddingException {
+        return wrap(newSecondaryKey, unwrap(oldSecondaryKey, tertiaryKey));
+    }
+
+    private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException {
+        return Cipher.getInstance(AES_GCM_MODE);
+    }
+
+    // Statics only
+    private KeyWrapUtils() {}
+}
diff --git a/packages/BackupEncryption/test/robolectric/Android.bp b/packages/BackupEncryption/test/robolectric/Android.bp
index 6d1abbb..3376ec9 100644
--- a/packages/BackupEncryption/test/robolectric/Android.bp
+++ b/packages/BackupEncryption/test/robolectric/Android.bp
@@ -20,6 +20,7 @@
     ],
     java_resource_dirs: ["config"],
     libs: [
+        "backup-encryption-protos",
         "platform-test-annotations",
         "testng",
     ],
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java
new file mode 100644
index 0000000..b607404
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.backup.encryption.keys;
+
+import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.protos.nano.WrappedKeyProto;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.security.InvalidKeyException;
+
+import javax.crypto.SecretKey;
+
+/** Key wrapping tests */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class KeyWrapUtilsTest {
+    private static final int KEY_SIZE_BITS = 256;
+    private static final int BITS_PER_BYTE = 8;
+    private static final int GCM_NONCE_LENGTH_BYTES = 16;
+    private static final int GCM_TAG_LENGTH_BYTES = 16;
+
+    /** Test a wrapped key has metadata */
+    @Test
+    public void wrap_addsMetadata() throws Exception {
+        WrappedKeyProto.WrappedKey wrappedKey =
+                KeyWrapUtils.wrap(
+                        /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey());
+        assertThat(wrappedKey.metadata).isNotNull();
+        assertThat(wrappedKey.metadata.type).isEqualTo(WrappedKeyProto.KeyMetadata.AES_256_GCM);
+    }
+
+    /** Test a wrapped key has an algorithm specified */
+    @Test
+    public void wrap_addsWrapAlgorithm() throws Exception {
+        WrappedKeyProto.WrappedKey wrappedKey =
+                KeyWrapUtils.wrap(
+                        /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey());
+        assertThat(wrappedKey.wrapAlgorithm).isEqualTo(WrappedKeyProto.WrappedKey.AES_256_GCM);
+    }
+
+    /** Test a wrapped key haas an nonce of the right length */
+    @Test
+    public void wrap_addsNonceOfAppropriateLength() throws Exception {
+        WrappedKeyProto.WrappedKey wrappedKey =
+                KeyWrapUtils.wrap(
+                        /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey());
+        assertThat(wrappedKey.nonce).hasLength(GCM_NONCE_LENGTH_BYTES);
+    }
+
+    /** Test a wrapped key has a key of the right length */
+    @Test
+    public void wrap_addsTagOfAppropriateLength() throws Exception {
+        WrappedKeyProto.WrappedKey wrappedKey =
+                KeyWrapUtils.wrap(
+                        /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey());
+        assertThat(wrappedKey.key).hasLength(KEY_SIZE_BITS / BITS_PER_BYTE + GCM_TAG_LENGTH_BYTES);
+    }
+
+    /** Ensure a key can be wrapped and unwrapped again */
+    @Test
+    public void unwrap_unwrapsEncryptedKey() throws Exception {
+        SecretKey secondaryKey = generateAesKey();
+        SecretKey tertiaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, tertiaryKey);
+        SecretKey unwrappedKey = KeyWrapUtils.unwrap(secondaryKey, wrappedKey);
+        assertThat(unwrappedKey).isEqualTo(tertiaryKey);
+    }
+
+    /** Ensure the unwrap method rejects keys with bad algorithms */
+    @Test(expected = InvalidKeyException.class)
+    public void unwrap_throwsForBadWrapAlgorithm() throws Exception {
+        SecretKey secondaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey());
+        wrappedKey.wrapAlgorithm = WrappedKeyProto.WrappedKey.UNKNOWN;
+
+        KeyWrapUtils.unwrap(secondaryKey, wrappedKey);
+    }
+
+    /** Ensure the unwrap method rejects metadata indicating the encryption type is unknown */
+    @Test(expected = InvalidKeyException.class)
+    public void unwrap_throwsForBadKeyAlgorithm() throws Exception {
+        SecretKey secondaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey());
+        wrappedKey.metadata.type = WrappedKeyProto.KeyMetadata.UNKNOWN;
+
+        KeyWrapUtils.unwrap(secondaryKey, wrappedKey);
+    }
+
+    /** Ensure the unwrap method rejects wrapped keys missing the metadata */
+    @Test(expected = InvalidKeyException.class)
+    public void unwrap_throwsForMissingMetadata() throws Exception {
+        SecretKey secondaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey());
+        wrappedKey.metadata = null;
+
+        KeyWrapUtils.unwrap(secondaryKey, wrappedKey);
+    }
+
+    /** Ensure unwrap rejects invalid secondary keys */
+    @Test(expected = InvalidKeyException.class)
+    public void unwrap_throwsForBadSecondaryKey() throws Exception {
+        WrappedKeyProto.WrappedKey wrappedKey =
+                KeyWrapUtils.wrap(
+                        /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey());
+
+        KeyWrapUtils.unwrap(generateAesKey(), wrappedKey);
+    }
+
+    /** Ensure rewrap can rewrap keys */
+    @Test
+    public void rewrap_canBeUnwrappedWithNewSecondaryKey() throws Exception {
+        SecretKey tertiaryKey = generateAesKey();
+        SecretKey oldSecondaryKey = generateAesKey();
+        SecretKey newSecondaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedWithOld = KeyWrapUtils.wrap(oldSecondaryKey, tertiaryKey);
+
+        WrappedKeyProto.WrappedKey wrappedWithNew =
+                KeyWrapUtils.rewrap(oldSecondaryKey, newSecondaryKey, wrappedWithOld);
+
+        assertThat(KeyWrapUtils.unwrap(newSecondaryKey, wrappedWithNew)).isEqualTo(tertiaryKey);
+    }
+
+    /** Ensure rewrap doesn't create something decryptable by an old key */
+    @Test(expected = InvalidKeyException.class)
+    public void rewrap_cannotBeUnwrappedWithOldSecondaryKey() throws Exception {
+        SecretKey tertiaryKey = generateAesKey();
+        SecretKey oldSecondaryKey = generateAesKey();
+        SecretKey newSecondaryKey = generateAesKey();
+        WrappedKeyProto.WrappedKey wrappedWithOld = KeyWrapUtils.wrap(oldSecondaryKey, tertiaryKey);
+
+        WrappedKeyProto.WrappedKey wrappedWithNew =
+                KeyWrapUtils.rewrap(oldSecondaryKey, newSecondaryKey, wrappedWithOld);
+
+        KeyWrapUtils.unwrap(oldSecondaryKey, wrappedWithNew);
+    }
+}
diff --git a/packages/PackageInstaller/src/com/android/packageinstaller/PackageInstallerActivity.java b/packages/PackageInstaller/src/com/android/packageinstaller/PackageInstallerActivity.java
index 55ff591..a2fa461 100644
--- a/packages/PackageInstaller/src/com/android/packageinstaller/PackageInstallerActivity.java
+++ b/packages/PackageInstaller/src/com/android/packageinstaller/PackageInstallerActivity.java
@@ -135,6 +135,7 @@
 
         mEnableOk = true;
         mOk.setEnabled(true);
+        mOk.setFilterTouchesWhenObscured(true);
     }
 
     /**
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
index 84f5a04..98db7c8 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CachedBluetoothDevice.java
@@ -27,6 +27,7 @@
 import android.os.ParcelUuid;
 import android.os.SystemClock;
 import android.text.TextUtils;
+import android.util.EventLog;
 import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
@@ -799,10 +800,9 @@
                         == BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE ||
                     mDevice.getBluetoothClass().getDeviceClass()
                         == BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET) {
-                    mDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_ALLOWED);
-                } else {
-                    mDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_REJECTED);
+                    EventLog.writeEvent(0x534e4554, "138529441", -1, "");
                 }
+                mDevice.setPhonebookAccessPermission(BluetoothDevice.ACCESS_REJECTED);
             }
         }
     }
diff --git a/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java b/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
index e4a8aab..720074b 100644
--- a/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
+++ b/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
@@ -217,8 +217,6 @@
         }
     }
 
-    private static KeyguardUpdateMonitor sInstance;
-
     private final Context mContext;
     private final boolean mIsPrimaryUser;
     HashMap<Integer, SimData> mSimDatas = new HashMap<Integer, SimData>();
@@ -1389,15 +1387,6 @@
         Trace.endSection();
     }
 
-
-    /** Provides access to the static instance. */
-    public static KeyguardUpdateMonitor getInstance(Context context) {
-        if (sInstance == null) {
-            sInstance = new KeyguardUpdateMonitor(context, Looper.getMainLooper());
-        }
-        return sInstance;
-    }
-
     protected void handleStartedGoingToSleep(int arg1) {
         checkIsHandlerThread();
         mLockIconPressed = false;
@@ -1830,7 +1819,10 @@
         return shouldListen;
     }
 
-    private boolean shouldListenForFace() {
+    /**
+     * If face auth is allows to scan on this exact moment.
+     */
+    public boolean shouldListenForFace() {
         final boolean awakeKeyguard = mKeyguardIsVisible && mDeviceInteractive && !mGoingToSleep;
         final int user = getCurrentUser();
         final int strongAuth = mStrongAuthTracker.getStrongAuthForUser(user);
diff --git a/packages/SystemUI/src/com/android/systemui/doze/DozeTriggers.java b/packages/SystemUI/src/com/android/systemui/doze/DozeTriggers.java
index 00bfb3f..bab64db 100644
--- a/packages/SystemUI/src/com/android/systemui/doze/DozeTriggers.java
+++ b/packages/SystemUI/src/com/android/systemui/doze/DozeTriggers.java
@@ -417,6 +417,9 @@
         mDozeSensors.dump(pw);
     }
 
+    /**
+     * @see DozeSensors.ProxSensor
+     */
     private abstract class ProximityCheck implements SensorEventListener, Runnable {
         private static final int TIMEOUT_DELAY_MS = 500;
 
@@ -428,6 +431,7 @@
         private boolean mRegistered;
         private boolean mFinished;
         private float mMaxRange;
+        private boolean mUsingBrightnessSensor;
 
         protected abstract void onProximityResult(int result);
 
@@ -435,6 +439,7 @@
             Preconditions.checkState(!mFinished && !mRegistered);
             Sensor sensor = DozeSensors.findSensorWithType(mSensorManager,
                     mContext.getString(R.string.doze_brightness_sensor_type));
+            mUsingBrightnessSensor = sensor != null;
             if (sensor == null) {
                 sensor = mSensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
             }
@@ -453,6 +458,9 @@
             mRegistered = true;
         }
 
+        /**
+         * @see DozeSensors.ProxSensor#onSensorChanged(SensorEvent)
+         */
         @Override
         public void onSensorChanged(SensorEvent event) {
             if (event.values.length == 0) {
@@ -462,7 +470,14 @@
                 if (DozeMachine.DEBUG) {
                     Log.d(TAG, "ProxCheck: Event: value=" + event.values[0] + " max=" + mMaxRange);
                 }
-                final boolean isNear = event.values[0] < mMaxRange;
+                final boolean isNear;
+                if (mUsingBrightnessSensor) {
+                    // The custom brightness sensor is gated by the proximity sensor and will
+                    // return 0 whenever prox is covered.
+                    isNear = event.values[0] == 0;
+                } else {
+                    isNear = event.values[0] < mMaxRange;
+                }
                 finishWithResult(isNear ? RESULT_NEAR : RESULT_FAR);
             }
         }
diff --git a/packages/SystemUI/src/com/android/systemui/globalactions/GlobalActionsDialog.java b/packages/SystemUI/src/com/android/systemui/globalactions/GlobalActionsDialog.java
index 3f598ff..c9c6a0c 100644
--- a/packages/SystemUI/src/com/android/systemui/globalactions/GlobalActionsDialog.java
+++ b/packages/SystemUI/src/com/android/systemui/globalactions/GlobalActionsDialog.java
@@ -614,7 +614,7 @@
             mHandler.postDelayed(new Runnable() {
                 @Override
                 public void run() {
-                    mScreenshotHelper.takeScreenshot(1, true, true, mHandler);
+                    mScreenshotHelper.takeScreenshot(1, true, true, mHandler, null);
                     MetricsLogger.action(mContext,
                             MetricsEvent.ACTION_SCREENSHOT_POWER_MENU);
                 }
diff --git a/packages/SystemUI/src/com/android/systemui/pip/phone/PipManager.java b/packages/SystemUI/src/com/android/systemui/pip/phone/PipManager.java
index 1740290..3be3422 100644
--- a/packages/SystemUI/src/com/android/systemui/pip/phone/PipManager.java
+++ b/packages/SystemUI/src/com/android/systemui/pip/phone/PipManager.java
@@ -16,6 +16,9 @@
 
 package com.android.systemui.pip.phone;
 
+import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+
 import android.app.ActivityManager;
 import android.app.ActivityTaskManager;
 import android.app.IActivityManager;
@@ -182,7 +185,6 @@
         ActivityManagerWrapper.getInstance().registerTaskStackListener(mTaskStackListener);
 
         mInputConsumerController = InputConsumerController.getPipInputConsumer();
-        mInputConsumerController.registerInputConsumer();
         mMediaController = new PipMediaController(context, mActivityManager);
         mMenuController = new PipMenuActivityController(context, mActivityManager, mMediaController,
                 mInputConsumerController);
@@ -190,6 +192,18 @@
                 mMenuController, mInputConsumerController);
         mAppOpsListener = new PipAppOpsListener(context, mActivityManager,
                 mTouchHandler.getMotionHelper());
+
+        // If SystemUI restart, and it already existed a pinned stack,
+        // register the pip input consumer to ensure touch can send to it.
+        try {
+            ActivityManager.StackInfo stackInfo = mActivityTaskManager.getStackInfo(
+                    WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
+            if (stackInfo != null) {
+                mInputConsumerController.registerInputConsumer();
+            }
+        } catch (RemoteException e) {
+            e.printStackTrace();
+        }
     }
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivity.java b/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivity.java
index 21f5812..4f2a6d8 100644
--- a/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivity.java
+++ b/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivity.java
@@ -48,7 +48,6 @@
 import android.content.Intent;
 import android.content.pm.ParceledListSlice;
 import android.graphics.Color;
-import android.graphics.PointF;
 import android.graphics.Rect;
 import android.graphics.drawable.ColorDrawable;
 import android.graphics.drawable.Drawable;
@@ -64,7 +63,6 @@
 import android.view.LayoutInflater;
 import android.view.MotionEvent;
 import android.view.View;
-import android.view.ViewConfiguration;
 import android.view.ViewGroup;
 import android.view.WindowManager.LayoutParams;
 import android.view.accessibility.AccessibilityManager;
@@ -92,6 +90,7 @@
     public static final int MESSAGE_UPDATE_ACTIONS = 4;
     public static final int MESSAGE_UPDATE_DISMISS_FRACTION = 5;
     public static final int MESSAGE_ANIMATION_ENDED = 6;
+    public static final int MESSAGE_TOUCH_EVENT = 7;
 
     private static final int INITIAL_DISMISS_DELAY = 3500;
     private static final int POST_INTERACTION_DISMISS_DELAY = 2000;
@@ -128,10 +127,6 @@
                 }
             };
 
-    private PipTouchState mTouchState;
-    private PointF mDownPosition = new PointF();
-    private PointF mDownDelta = new PointF();
-    private ViewConfiguration mViewConfig;
     private Handler mHandler = new Handler();
     private Messenger mToControllerMessenger;
     private Messenger mMessenger = new Messenger(new Handler() {
@@ -169,6 +164,12 @@
                     mAllowTouches = true;
                     break;
                 }
+
+                case MESSAGE_TOUCH_EVENT: {
+                    final MotionEvent ev = (MotionEvent) msg.obj;
+                    dispatchTouchEvent(ev);
+                    break;
+                }
             }
         }
     });
@@ -184,15 +185,7 @@
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         // Set the flags to allow us to watch for outside touches and also hide the menu and start
         // manipulating the PIP in the same touch gesture
-        mViewConfig = ViewConfiguration.get(this);
-        mTouchState = new PipTouchState(mViewConfig, mHandler, () -> {
-            if (mMenuState == MENU_STATE_CLOSE) {
-                showPipMenu();
-            } else {
-                expandPip();
-            }
-        });
-        getWindow().addFlags(LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | LayoutParams.FLAG_SLIPPERY);
+        getWindow().addFlags(LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
 
         super.onCreate(savedInstanceState);
         setContentView(R.layout.pip_menu_activity);
@@ -204,32 +197,6 @@
         mViewRoot.setBackground(mBackgroundDrawable);
         mMenuContainer = findViewById(R.id.menu_container);
         mMenuContainer.setAlpha(0);
-        mMenuContainer.setOnTouchListener((v, event) -> {
-            mTouchState.onTouchEvent(event);
-            switch (event.getAction()) {
-                case MotionEvent.ACTION_UP:
-                    if (mTouchState.isDoubleTap() || mMenuState == MENU_STATE_FULL) {
-                        // Expand to fullscreen if this is a double tap or we are already expanded
-                        expandPip();
-                    } else if (!mTouchState.isWaitingForDoubleTap()) {
-                        // User has stalled long enough for this not to be a drag or a double tap,
-                        // just expand the menu if necessary
-                        if (mMenuState == MENU_STATE_CLOSE) {
-                            showPipMenu();
-                        }
-                    } else {
-                        // Next touch event _may_ be the second tap for the double-tap, schedule a
-                        // fallback runnable to trigger the menu if no touch event occurs before the
-                        // next tap
-                        mTouchState.scheduleDoubleTapTimeoutCallback();
-                    }
-                    // Fall through
-                case MotionEvent.ACTION_CANCEL:
-                    mTouchState.reset();
-                    break;
-            }
-            return true;
-        });
         mSettingsButton = findViewById(R.id.settings);
         mSettingsButton.setAlpha(0);
         mSettingsButton.setOnClickListener((v) -> {
@@ -240,7 +207,11 @@
         mDismissButton = findViewById(R.id.dismiss);
         mDismissButton.setAlpha(0);
         mDismissButton.setOnClickListener(v -> dismissPip());
-        findViewById(R.id.expand_button).setOnClickListener(v -> expandPip());
+        findViewById(R.id.expand_button).setOnClickListener(v -> {
+            if (mMenuContainer.getAlpha() != 0) {
+                expandPip();
+            }
+        });
         mActionsGroup = findViewById(R.id.actions_group);
         mBetweenActionPaddingLand = getResources().getDimensionPixelSize(
                 R.dimen.pip_between_action_padding_land);
@@ -298,27 +269,14 @@
     @Override
     public boolean dispatchTouchEvent(MotionEvent ev) {
         if (!mAllowTouches) {
-            return super.dispatchTouchEvent(ev);
+            return false;
         }
 
         // On the first action outside the window, hide the menu
         switch (ev.getAction()) {
             case MotionEvent.ACTION_OUTSIDE:
                 hideMenu();
-                break;
-            case MotionEvent.ACTION_DOWN:
-                mDownPosition.set(ev.getX(), ev.getY());
-                mDownDelta.set(0f, 0f);
-                break;
-            case MotionEvent.ACTION_MOVE:
-                mDownDelta.set(ev.getX() - mDownPosition.x, ev.getY() - mDownPosition.y);
-                if (mDownDelta.length() > mViewConfig.getScaledTouchSlop()
-                        && mMenuState != MENU_STATE_NONE) {
-                    // Restore the input consumer and let that drive the movement of this menu
-                    notifyRegisterInputConsumer();
-                    cancelDelayedFinish();
-                }
-                break;
+                return true;
         }
         return super.dispatchTouchEvent(ev);
     }
@@ -381,7 +339,6 @@
             if (allowMenuTimeout) {
                 repostDelayedFinish(POST_INTERACTION_DISMISS_DELAY);
             }
-            notifyUnregisterInputConsumer();
         }
     }
 
@@ -506,11 +463,13 @@
                     actionView.setContentDescription(action.getContentDescription());
                     if (action.isEnabled()) {
                         actionView.setOnClickListener(v -> {
-                            try {
-                                action.getActionIntent().send();
-                            } catch (CanceledException e) {
-                                Log.w(TAG, "Failed to send action", e);
-                            }
+                            mHandler.post(() -> {
+                                try {
+                                    action.getActionIntent().send();
+                                } catch (CanceledException e) {
+                                    Log.w(TAG, "Failed to send action", e);
+                                }
+                            });
                         });
                     }
                     actionView.setEnabled(action.isEnabled());
@@ -554,18 +513,6 @@
         mBackgroundDrawable.setAlpha(alpha);
     }
 
-    private void notifyRegisterInputConsumer() {
-        Message m = Message.obtain();
-        m.what = PipMenuActivityController.MESSAGE_REGISTER_INPUT_CONSUMER;
-        sendMessage(m, "Could not notify controller to register input consumer");
-    }
-
-    private void notifyUnregisterInputConsumer() {
-        Message m = Message.obtain();
-        m.what = PipMenuActivityController.MESSAGE_UNREGISTER_INPUT_CONSUMER;
-        sendMessage(m, "Could not notify controller to unregister input consumer");
-    }
-
     private void notifyMenuStateChange(int menuState) {
         mMenuState = menuState;
         Message m = Message.obtain();
@@ -583,11 +530,6 @@
         }, false /* notifyMenuVisibility */, false /* isDismissing */);
     }
 
-    private void minimizePip() {
-        sendEmptyMessage(PipMenuActivityController.MESSAGE_MINIMIZE_PIP,
-                "Could not notify controller to minimize PIP");
-    }
-
     private void dismissPip() {
         // Do not notify menu visibility when hiding the menu, the controller will do this when it
         // handles the message
@@ -597,12 +539,6 @@
         }, false /* notifyMenuVisibility */, true /* isDismissing */);
     }
 
-    private void showPipMenu() {
-        Message m = Message.obtain();
-        m.what = PipMenuActivityController.MESSAGE_SHOW_MENU;
-        sendMessage(m, "Could not notify controller to show PIP menu");
-    }
-
     private void showSettings() {
         final Pair<ComponentName, Integer> topPipActivityInfo =
                 PipUtils.getTopPinnedActivity(this, ActivityManager.getService());
diff --git a/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivityController.java b/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivityController.java
index 14459d6..62c59e5 100644
--- a/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivityController.java
+++ b/packages/SystemUI/src/com/android/systemui/pip/phone/PipMenuActivityController.java
@@ -37,6 +37,7 @@
 import android.os.SystemClock;
 import android.os.UserHandle;
 import android.util.Log;
+import android.view.MotionEvent;
 
 import com.android.systemui.pip.phone.PipMediaController.ActionListener;
 import com.android.systemui.shared.system.InputConsumerController;
@@ -156,14 +157,6 @@
                     mListeners.forEach(l -> l.onPipShowMenu());
                     break;
                 }
-                case MESSAGE_REGISTER_INPUT_CONSUMER: {
-                    mInputConsumerController.registerInputConsumer();
-                    break;
-                }
-                case MESSAGE_UNREGISTER_INPUT_CONSUMER: {
-                    mInputConsumerController.unregisterInputConsumer();
-                    break;
-                }
                 case MESSAGE_UPDATE_ACTIVITY_CALLBACK: {
                     mToActivityMessenger = msg.replyTo;
                     setStartActivityRequested(false);
@@ -212,15 +205,12 @@
     }
 
     public void onActivityPinned() {
-        if (mMenuState == MENU_STATE_NONE) {
-            // If the menu is not visible, then re-register the input consumer if it is not already
-            // registered
-            mInputConsumerController.registerInputConsumer();
-        }
+        mInputConsumerController.registerInputConsumer();
     }
 
     public void onActivityUnpinned() {
         hideMenu();
+        mInputConsumerController.unregisterInputConsumer();
         setStartActivityRequested(false);
     }
 
@@ -495,11 +485,7 @@
             Log.d(TAG, "onMenuStateChanged() mMenuState=" + mMenuState
                     + " menuState=" + menuState + " resize=" + resize);
         }
-        if (menuState == MENU_STATE_NONE) {
-            mInputConsumerController.registerInputConsumer();
-        } else {
-            mInputConsumerController.unregisterInputConsumer();
-        }
+
         if (menuState != mMenuState) {
             mListeners.forEach(l -> l.onPipMenuStateChanged(menuState, resize));
             if (menuState == MENU_STATE_FULL) {
@@ -521,6 +507,22 @@
         mStartActivityRequestedTime = requested ? SystemClock.uptimeMillis() : 0;
     }
 
+    /**
+     * Handles touch event sent from pip input consumer.
+     */
+    void handleTouchEvent(MotionEvent ev) {
+        if (mToActivityMessenger != null) {
+            Message m = Message.obtain();
+            m.what = PipMenuActivity.MESSAGE_TOUCH_EVENT;
+            m.obj = ev;
+            try {
+                mToActivityMessenger.send(m);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Could not dispatch touch event", e);
+            }
+        }
+    }
+
     public void dump(PrintWriter pw, String prefix) {
         final String innerPrefix = prefix + "  ";
         pw.println(prefix + TAG);
diff --git a/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchHandler.java b/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchHandler.java
index b05058a..30cf412 100644
--- a/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchHandler.java
@@ -363,6 +363,8 @@
         // Update the touch state
         mTouchState.onTouchEvent(ev);
 
+        boolean shouldDeliverToMenu = mMenuState != MENU_STATE_NONE;
+
         switch (ev.getAction()) {
             case MotionEvent.ACTION_DOWN: {
                 mMotionHelper.synchronizePinnedStackBounds();
@@ -378,6 +380,8 @@
                         break;
                     }
                 }
+
+                shouldDeliverToMenu = !mTouchState.isDragging();
                 break;
             }
             case MotionEvent.ACTION_UP: {
@@ -394,6 +398,7 @@
                 // Fall through to clean up
             }
             case MotionEvent.ACTION_CANCEL: {
+                shouldDeliverToMenu = !mTouchState.startedDragging() && !mTouchState.isDragging();
                 mTouchState.reset();
                 break;
             }
@@ -425,7 +430,20 @@
                 break;
             }
         }
-        return mMenuState == MENU_STATE_NONE;
+
+        // Deliver the event to PipMenuActivity to handle button click if the menu has shown.
+        if (shouldDeliverToMenu) {
+            final MotionEvent cloneEvent = MotionEvent.obtain(ev);
+            // Send the cancel event and cancel menu timeout if it starts to drag.
+            if (mTouchState.startedDragging()) {
+                cloneEvent.setAction(MotionEvent.ACTION_CANCEL);
+                mMenuController.pokeMenu();
+            }
+
+            mMenuController.handleTouchEvent(cloneEvent);
+        }
+
+        return true;
     }
 
     /**
@@ -741,11 +759,11 @@
                 mMotionHelper.animateToClosestSnapTarget(mMovementBounds, null /* updateListener */,
                         null /* animatorListener */);
                 setMinimizedStateInternal(false);
+            } else if (mTouchState.isDoubleTap()) {
+                // Expand to fullscreen if this is a double tap
+                mMotionHelper.expandPip();
             } else if (mMenuState != MENU_STATE_FULL) {
-                if (mTouchState.isDoubleTap()) {
-                    // Expand to fullscreen if this is a double tap
-                    mMotionHelper.expandPip();
-                } else if (!mTouchState.isWaitingForDoubleTap()) {
+                if (!mTouchState.isWaitingForDoubleTap()) {
                     // User has stalled long enough for this not to be a drag or a double tap, just
                     // expand the menu
                     mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
@@ -756,9 +774,6 @@
                     // next tap
                     mTouchState.scheduleDoubleTapTimeoutCallback();
                 }
-            } else {
-                mMenuController.hideMenu();
-                mMotionHelper.expandPip();
             }
             return true;
         }
diff --git a/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchState.java b/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchState.java
index 69efbc8..e3f65ef 100644
--- a/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchState.java
+++ b/packages/SystemUI/src/com/android/systemui/pip/phone/PipTouchState.java
@@ -106,6 +106,7 @@
                 mIsDoubleTap = !mPreviouslyDragging &&
                         (mDownTouchTime - mLastDownTouchTime) < DOUBLE_TAP_TIMEOUT;
                 mIsWaitingForDoubleTap = false;
+                mIsDragging = false;
                 mLastDownTouchTime = mDownTouchTime;
                 if (mDoubleTapTimeoutCallback != null) {
                     mHandler.removeCallbacks(mDoubleTapTimeoutCallback);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java
index f91b03e..a79ecd9 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java
@@ -39,6 +39,7 @@
 import android.graphics.PorterDuffXfermode;
 import android.graphics.Rect;
 import android.graphics.Region;
+import android.hardware.biometrics.BiometricSourceType;
 import android.os.PowerManager;
 import android.os.SystemClock;
 import android.provider.DeviceConfig;
@@ -62,6 +63,7 @@
 import com.android.keyguard.KeyguardClockSwitch;
 import com.android.keyguard.KeyguardStatusView;
 import com.android.keyguard.KeyguardUpdateMonitor;
+import com.android.keyguard.KeyguardUpdateMonitorCallback;
 import com.android.systemui.DejankUtils;
 import com.android.systemui.Dependency;
 import com.android.systemui.Interpolators;
@@ -172,6 +174,25 @@
             R.id.keyguard_hun_animator_start_tag);
     private static final AnimationProperties KEYGUARD_HUN_PROPERTIES =
             new AnimationProperties().setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD);
+    @VisibleForTesting
+    final KeyguardUpdateMonitorCallback mKeyguardUpdateCallback =
+            new KeyguardUpdateMonitorCallback() {
+                @Override
+                public void onBiometricRunningStateChanged(boolean running,
+                        BiometricSourceType biometricSourceType) {
+                    boolean keyguardOrShadeLocked = mBarState == StatusBarState.KEYGUARD
+                            || mBarState == StatusBarState.SHADE_LOCKED;
+                    if (!running && mFirstBypassAttempt && keyguardOrShadeLocked && !mDozing) {
+                        mFirstBypassAttempt = false;
+                        animateKeyguardStatusBarIn(StackStateAnimator.ANIMATION_DURATION_STANDARD);
+                    }
+                }
+
+                @Override
+                public void onFinishedGoingToSleep(int why) {
+                    mFirstBypassAttempt = mKeyguardBypassController.getBypassEnabled();
+                }
+            };
 
     private final InjectionInflationController mInjectionInflationController;
     private final PowerManager mPowerManager;
@@ -392,6 +413,7 @@
     private boolean mShowingKeyguardHeadsUp;
     private boolean mAllowExpandForSmallExpansion;
     private Runnable mExpandAfterLayoutRunnable;
+    private boolean mFirstBypassAttempt;
 
     private PluginManager mPluginManager;
     private FrameLayout mPluginFrame;
@@ -427,6 +449,7 @@
         mThemeResId = context.getThemeResId();
         mKeyguardBypassController = bypassController;
         mUpdateMonitor = Dependency.get(KeyguardUpdateMonitor.class);
+        mFirstBypassAttempt = mKeyguardBypassController.getBypassEnabled();
         dynamicPrivacyController.addListener(this);
 
         mBottomAreaShadeAlphaAnimator = ValueAnimator.ofFloat(1f, 0);
@@ -519,6 +542,7 @@
         Dependency.get(StatusBarStateController.class).addCallback(this);
         Dependency.get(ZenModeController.class).addCallback(this);
         Dependency.get(ConfigurationController.class).addCallback(this);
+        mUpdateMonitor.registerCallback(mKeyguardUpdateCallback);
         // Theme might have changed between inflating this view and attaching it to the window, so
         // force a call to onThemeChanged
         onThemeChanged();
@@ -531,6 +555,7 @@
         Dependency.get(StatusBarStateController.class).removeCallback(this);
         Dependency.get(ZenModeController.class).removeCallback(this);
         Dependency.get(ConfigurationController.class).removeCallback(this);
+        mUpdateMonitor.removeCallback(mKeyguardUpdateCallback);
     }
 
     @Override
@@ -2366,7 +2391,9 @@
                 * mKeyguardStatusBarAnimateAlpha;
         newAlpha *= 1.0f - mKeyguardHeadsUpShowingAmount;
         mKeyguardStatusBar.setAlpha(newAlpha);
-        mKeyguardStatusBar.setVisibility(newAlpha != 0f && !mDozing ? VISIBLE : INVISIBLE);
+        boolean hideForBypass = mFirstBypassAttempt && mUpdateMonitor.shouldListenForFace();
+        mKeyguardStatusBar.setVisibility(newAlpha != 0f && !mDozing && !hideForBypass
+                ? VISIBLE : INVISIBLE);
     }
 
     private void updateKeyguardBottomAreaAlpha() {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/NotificationPanelViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/NotificationPanelViewTest.java
index 86ab3a7..2f24494 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/NotificationPanelViewTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/NotificationPanelViewTest.java
@@ -21,19 +21,23 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.app.StatusBarManager;
+import android.hardware.biometrics.BiometricSourceType;
 import android.testing.AndroidTestingRunner;
 import android.testing.TestableLooper;
 import android.view.MotionEvent;
+import android.view.View;
 import android.view.ViewGroup;
 
 import androidx.test.filters.SmallTest;
 
 import com.android.keyguard.KeyguardStatusView;
+import com.android.keyguard.KeyguardUpdateMonitor;
 import com.android.systemui.SystemUIFactory;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.plugins.FalsingManager;
@@ -51,7 +55,6 @@
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout;
 import com.android.systemui.statusbar.policy.ConfigurationController;
 import com.android.systemui.statusbar.policy.ZenModeController;
-import com.android.systemui.tuner.TunerService;
 import com.android.systemui.util.InjectionInflationController;
 
 import org.junit.Before;
@@ -101,7 +104,11 @@
     @Mock
     private KeyguardAffordanceHelper mAffordanceHelper;
     @Mock
+    private KeyguardUpdateMonitor mUpdateMonitor;
+    @Mock
     private FalsingManager mFalsingManager;
+    @Mock
+    private KeyguardBypassController mKeyguardBypassController;
     private NotificationPanelView mNotificationPanelView;
 
     @Before
@@ -112,22 +119,21 @@
         when(mHeadsUpCallback.getContext()).thenReturn(mContext);
         mDependency.injectTestDependency(StatusBarStateController.class,
                 mStatusBarStateController);
+        mDependency.injectTestDependency(KeyguardUpdateMonitor.class, mUpdateMonitor);
         mDependency.injectMockDependency(ShadeController.class);
         mDependency.injectMockDependency(NotificationLockscreenUserManager.class);
         mDependency.injectMockDependency(ConfigurationController.class);
         mDependency.injectMockDependency(ZenModeController.class);
-        KeyguardBypassController bypassController = new KeyguardBypassController(mContext,
-                mock(TunerService.class), mStatusBarStateController,
-                mock(NotificationLockscreenUserManager.class));
         NotificationWakeUpCoordinator coordinator =
                 new NotificationWakeUpCoordinator(mContext,
                         mock(HeadsUpManagerPhone.class),
                         new StatusBarStateControllerImpl(),
-                        bypassController);
+                        mKeyguardBypassController);
         PulseExpansionHandler expansionHandler = new PulseExpansionHandler(mContext, coordinator,
-                bypassController, mHeadsUpManager, mock(NotificationRoundnessManager.class));
+                mKeyguardBypassController, mHeadsUpManager,
+                mock(NotificationRoundnessManager.class));
         mNotificationPanelView = new TestableNotificationPanelView(coordinator, expansionHandler,
-                bypassController);
+                mKeyguardBypassController);
         mNotificationPanelView.setHeadsUpManager(mHeadsUpManager);
         mNotificationPanelView.setBar(mPanelBar);
 
@@ -187,6 +193,20 @@
         assertThat(mNotificationPanelView.isTrackingBlocked()).isFalse();
     }
 
+    @Test
+    public void testKeyguardStatusBarVisibility_hiddenForBypass() {
+        when(mUpdateMonitor.shouldListenForFace()).thenReturn(true);
+        mNotificationPanelView.mKeyguardUpdateCallback.onBiometricRunningStateChanged(true,
+                BiometricSourceType.FACE);
+        verify(mKeyguardStatusBar, never()).setVisibility(View.VISIBLE);
+
+        when(mKeyguardBypassController.getBypassEnabled()).thenReturn(true);
+        mNotificationPanelView.mKeyguardUpdateCallback.onFinishedGoingToSleep(0);
+        mNotificationPanelView.mKeyguardUpdateCallback.onBiometricRunningStateChanged(true,
+                BiometricSourceType.FACE);
+        verify(mKeyguardStatusBar, never()).setVisibility(View.VISIBLE);
+    }
+
     private class TestableNotificationPanelView extends NotificationPanelView {
         TestableNotificationPanelView(NotificationWakeUpCoordinator coordinator,
                 PulseExpansionHandler expansionHandler,
diff --git a/services/accessibility/java/com/android/server/accessibility/GlobalActionPerformer.java b/services/accessibility/java/com/android/server/accessibility/GlobalActionPerformer.java
index 672518c..b9b2654 100644
--- a/services/accessibility/java/com/android/server/accessibility/GlobalActionPerformer.java
+++ b/services/accessibility/java/com/android/server/accessibility/GlobalActionPerformer.java
@@ -24,10 +24,7 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.os.PowerManager;
-import android.os.RemoteException;
-import android.os.ServiceManager;
 import android.os.SystemClock;
-import android.view.IWindowManager;
 import android.view.InputDevice;
 import android.view.KeyCharacterMap;
 import android.view.KeyEvent;
@@ -191,7 +188,7 @@
         ScreenshotHelper screenshotHelper = (mScreenshotHelperSupplier != null)
                 ? mScreenshotHelperSupplier.get() : new ScreenshotHelper(mContext);
         screenshotHelper.takeScreenshot(android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN,
-                true, true, new Handler(Looper.getMainLooper()));
+                true, true, new Handler(Looper.getMainLooper()), null);
         return true;
     }
 }
diff --git a/services/core/java/com/android/server/PackageWatchdog.java b/services/core/java/com/android/server/PackageWatchdog.java
index 5672a13..3fbb21e 100644
--- a/services/core/java/com/android/server/PackageWatchdog.java
+++ b/services/core/java/com/android/server/PackageWatchdog.java
@@ -865,8 +865,8 @@
                 MonitoredPackage p = it.next();
                 int oldState = p.getHealthCheckStateLocked();
                 int newState = p.handleElapsedTimeLocked(elapsedMs);
-                if (oldState != MonitoredPackage.STATE_FAILED
-                        && newState == MonitoredPackage.STATE_FAILED) {
+                if (oldState != HealthCheckState.FAILED
+                        && newState == HealthCheckState.FAILED) {
                     Slog.i(TAG, "Package " + p.mName + " failed health check");
                     failedPackages.add(p);
                 }
@@ -941,6 +941,23 @@
         }
     }
 
+    @Retention(SOURCE)
+    @IntDef(value = {
+            HealthCheckState.ACTIVE,
+            HealthCheckState.INACTIVE,
+            HealthCheckState.PASSED,
+            HealthCheckState.FAILED})
+    public @interface HealthCheckState {
+        // The package has not passed health check but has requested a health check
+        int ACTIVE = 0;
+        // The package has not passed health check and has not requested a health check
+        int INACTIVE = 1;
+        // The package has passed health check
+        int PASSED = 2;
+        // The package has failed health check
+        int FAILED = 3;
+    }
+
     /**
      * Represents a package and its health check state along with the time
      * it should be monitored for.
@@ -949,23 +966,12 @@
      * instances of this class.
      */
     class MonitoredPackage {
-        // Health check states
-        // TODO(b/120598832): Prefix with HEALTH_CHECK
-        // mName has not passed health check but has requested a health check
-        public static final int STATE_ACTIVE = 0;
-        // mName has not passed health check and has not requested a health check
-        public static final int STATE_INACTIVE = 1;
-        // mName has passed health check
-        public static final int STATE_PASSED = 2;
-        // mName has failed health check
-        public static final int STATE_FAILED = 3;
-
         //TODO(b/120598832): VersionedPackage?
         private final String mName;
         // One of STATE_[ACTIVE|INACTIVE|PASSED|FAILED]. Updated on construction and after
         // methods that could change the health check state: handleElapsedTimeLocked and
         // tryPassHealthCheckLocked
-        private int mHealthCheckState = STATE_INACTIVE;
+        private int mHealthCheckState = HealthCheckState.INACTIVE;
         // Whether an explicit health check has passed.
         // This value in addition with mHealthCheckDurationMs determines the health check state
         // of the package, see #getHealthCheckStateLocked
@@ -1052,7 +1058,7 @@
                         + ". Using total duration " + mDurationMs + "ms instead");
                 initialHealthCheckDurationMs = mDurationMs;
             }
-            if (mHealthCheckState == STATE_INACTIVE) {
+            if (mHealthCheckState == HealthCheckState.INACTIVE) {
                 // Transitions to ACTIVE
                 mHealthCheckDurationMs = initialHealthCheckDurationMs;
             }
@@ -1072,7 +1078,7 @@
             }
             // Transitions to FAILED if now <= 0 and health check not passed
             mDurationMs -= elapsedMs;
-            if (mHealthCheckState == STATE_ACTIVE) {
+            if (mHealthCheckState == HealthCheckState.ACTIVE) {
                 // We only update health check durations if we have #setHealthCheckActiveLocked
                 // This ensures we don't leave the INACTIVE state for an unexpected elapsed time
                 // Transitions to FAILED if now <= 0 and health check not passed
@@ -1082,14 +1088,15 @@
         }
 
         /**
-         * Marks the health check as passed and transitions to {@link #STATE_PASSED}
-         * if not yet {@link #STATE_FAILED}.
+         * Marks the health check as passed and transitions to {@link HealthCheckState.PASSED}
+         * if not yet {@link HealthCheckState.FAILED}.
          *
-         * @return the new health check state
+         * @return the new {@link HealthCheckState health check state}
          */
         @GuardedBy("mLock")
+        @HealthCheckState
         public int tryPassHealthCheckLocked() {
-            if (mHealthCheckState != STATE_FAILED) {
+            if (mHealthCheckState != HealthCheckState.FAILED) {
                 // FAILED is a final state so only pass if we haven't failed
                 // Transition to PASSED
                 mHasPassedHealthCheck = true;
@@ -1102,12 +1109,11 @@
             return mName;
         }
 
-        //TODO(b/120598832): IntDef
         /**
-         * Returns the current health check state, any of {@link #STATE_ACTIVE},
-         * {@link #STATE_INACTIVE} or {@link #STATE_PASSED}
+         * Returns the current {@link HealthCheckState health check state}.
          */
         @GuardedBy("mLock")
+        @HealthCheckState
         public int getHealthCheckStateLocked() {
             return mHealthCheckState;
         }
@@ -1140,28 +1146,30 @@
          */
         @GuardedBy("mLock")
         public boolean isPendingHealthChecksLocked() {
-            return mHealthCheckState == STATE_ACTIVE || mHealthCheckState == STATE_INACTIVE;
+            return mHealthCheckState == HealthCheckState.ACTIVE
+                    || mHealthCheckState == HealthCheckState.INACTIVE;
         }
 
         /**
          * Updates the health check state based on {@link #mHasPassedHealthCheck}
          * and {@link #mHealthCheckDurationMs}.
          *
-         * @return the new health check state
+         * @return the new {@link HealthCheckState health check state}
          */
         @GuardedBy("mLock")
+        @HealthCheckState
         private int updateHealthCheckStateLocked() {
             int oldState = mHealthCheckState;
             if (mHasPassedHealthCheck) {
                 // Set final state first to avoid ambiguity
-                mHealthCheckState = STATE_PASSED;
+                mHealthCheckState = HealthCheckState.PASSED;
             } else if (mHealthCheckDurationMs <= 0 || mDurationMs <= 0) {
                 // Set final state first to avoid ambiguity
-                mHealthCheckState = STATE_FAILED;
+                mHealthCheckState = HealthCheckState.FAILED;
             } else if (mHealthCheckDurationMs == Long.MAX_VALUE) {
-                mHealthCheckState = STATE_INACTIVE;
+                mHealthCheckState = HealthCheckState.INACTIVE;
             } else {
-                mHealthCheckState = STATE_ACTIVE;
+                mHealthCheckState = HealthCheckState.ACTIVE;
             }
             Slog.i(TAG, "Updated health check state for package " + mName + ": "
                     + toString(oldState) + " -> " + toString(mHealthCheckState));
@@ -1169,15 +1177,15 @@
         }
 
         /** Returns a {@link String} representation of the current health check state. */
-        private String toString(int state) {
+        private String toString(@HealthCheckState int state) {
             switch (state) {
-                case STATE_ACTIVE:
+                case HealthCheckState.ACTIVE:
                     return "ACTIVE";
-                case STATE_INACTIVE:
+                case HealthCheckState.INACTIVE:
                     return "INACTIVE";
-                case STATE_PASSED:
+                case HealthCheckState.PASSED:
                     return "PASSED";
-                case STATE_FAILED:
+                case HealthCheckState.FAILED:
                     return "FAILED";
                 default:
                     return "UNKNOWN";
diff --git a/services/core/java/com/android/server/TelephonyRegistry.java b/services/core/java/com/android/server/TelephonyRegistry.java
index f7e825e..eb2723a 100644
--- a/services/core/java/com/android/server/TelephonyRegistry.java
+++ b/services/core/java/com/android/server/TelephonyRegistry.java
@@ -79,7 +79,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
-import java.util.stream.Collectors;
 
 /**
  * Since phone process can be restarted, this class provides a centralized place
@@ -849,10 +848,7 @@
                         }
                     }
                     if ((events & PhoneStateListener
-                            .LISTEN_ACTIVE_DATA_SUBSCRIPTION_ID_CHANGE) != 0
-                            && TelephonyPermissions.checkReadPhoneStateOnAnyActiveSub(
-                                    r.context, r.callerPid, r.callerUid, r.callingPackage,
-                            "listen_active_data_subid_change")) {
+                            .LISTEN_ACTIVE_DATA_SUBSCRIPTION_ID_CHANGE) != 0) {
                         try {
                             r.callback.onActiveDataSubIdChanged(mActiveDataSubId);
                         } catch (RemoteException ex) {
@@ -1829,23 +1825,11 @@
             log("notifyActiveDataSubIdChanged: activeDataSubId=" + activeDataSubId);
         }
 
-        // Create a copy to prevent the IPC call while checking carrier privilege under the lock.
-        List<Record> copiedRecords;
-        synchronized (mRecords) {
-            copiedRecords = new ArrayList<>(mRecords);
-        }
         mActiveDataSubId = activeDataSubId;
-
-        // Filter the record that does not listen to this change or does not have the permission.
-        copiedRecords = copiedRecords.stream().filter(r -> r.matchPhoneStateListenerEvent(
-                PhoneStateListener.LISTEN_ACTIVE_DATA_SUBSCRIPTION_ID_CHANGE)
-                && TelephonyPermissions.checkReadPhoneStateOnAnyActiveSub(
-                        mContext, r.callerPid, r.callerUid, r.callingPackage,
-                "notifyActiveDataSubIdChanged")).collect(Collectors.toCollection(ArrayList::new));
-
         synchronized (mRecords) {
-            for (Record r : copiedRecords) {
-                if (mRecords.contains(r)) {
+            for (Record r : mRecords) {
+                if (r.matchPhoneStateListenerEvent(
+                        PhoneStateListener.LISTEN_ACTIVE_DATA_SUBSCRIPTION_ID_CHANGE)) {
                     try {
                         r.callback.onActiveDataSubIdChanged(activeDataSubId);
                     } catch (RemoteException ex) {
diff --git a/services/core/java/com/android/server/content/ContentService.java b/services/core/java/com/android/server/content/ContentService.java
index 36e872a..4a62bc5 100644
--- a/services/core/java/com/android/server/content/ContentService.java
+++ b/services/core/java/com/android/server/content/ContentService.java
@@ -55,6 +55,7 @@
 import android.os.ShellCallback;
 import android.os.UserHandle;
 import android.text.TextUtils;
+import android.text.format.DateUtils;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
@@ -64,6 +65,8 @@
 import android.util.SparseIntArray;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.os.BackgroundThread;
 import com.android.internal.os.BinderDeathDispatcher;
 import com.android.internal.util.ArrayUtils;
 import com.android.internal.util.DumpUtils;
@@ -89,6 +92,14 @@
     /** Do a WTF if a single observer is registered more than this times. */
     private static final int TOO_MANY_OBSERVERS_THRESHOLD = 1000;
 
+    /**
+     * Delay to apply to content change notifications dispatched to apps running
+     * in the background. This is used to help prevent stampeding when the user
+     * is performing CPU/RAM intensive foreground tasks, such as when capturing
+     * burst photos.
+     */
+    private static final long BACKGROUND_OBSERVER_DELAY = 10 * DateUtils.SECOND_IN_MILLIS;
+
     public static class Lifecycle extends SystemService {
         private ContentService mService;
 
@@ -426,28 +437,15 @@
                         flags, userHandle, calls);
             }
             final int numCalls = calls.size();
-            for (int i=0; i<numCalls; i++) {
-                ObserverCall oc = calls.get(i);
-                try {
-                    oc.mObserver.onChange(oc.mSelfChange, uri, userHandle);
-                    if (DEBUG) Slog.d(TAG, "Notified " + oc.mObserver + " of " + "update at "
-                            + uri);
-                } catch (RemoteException ex) {
-                    synchronized (mRootNode) {
-                        Log.w(TAG, "Found dead observer, removing");
-                        IBinder binder = oc.mObserver.asBinder();
-                        final ArrayList<ObserverNode.ObserverEntry> list
-                                = oc.mNode.mObservers;
-                        int numList = list.size();
-                        for (int j=0; j<numList; j++) {
-                            ObserverNode.ObserverEntry oe = list.get(j);
-                            if (oe.observer.asBinder() == binder) {
-                                list.remove(j);
-                                j--;
-                                numList--;
-                            }
-                        }
-                    }
+            for (int i = 0; i < numCalls; i++) {
+                // Immediately dispatch notifications to foreground apps that
+                // are important to the user; all other background observers are
+                // delayed to avoid stampeding
+                final ObserverCall oc = calls.get(i);
+                if (oc.mProcState <= ActivityManager.PROCESS_STATE_IMPORTANT_FOREGROUND) {
+                    oc.run();
+                } else {
+                    BackgroundThread.getHandler().postDelayed(oc, BACKGROUND_OBSERVER_DELAY);
                 }
             }
             if ((flags&ContentResolver.NOTIFY_SYNC_TO_NETWORK) != 0) {
@@ -486,23 +484,33 @@
                 UserHandle.getCallingUserId(), Build.VERSION_CODES.CUR_DEVELOPMENT, callingPackage);
     }
 
-    /**
-     * Hide this class since it is not part of api,
-     * but current unittest framework requires it to be public
-     * @hide
-     *
-     */
-    public static final class ObserverCall {
-        final ObserverNode mNode;
+    /** {@hide} */
+    @VisibleForTesting
+    public static final class ObserverCall implements Runnable {
         final IContentObserver mObserver;
         final boolean mSelfChange;
-        final int mObserverUserId;
+        final Uri mUri;
+        final int mUserId;
+        final int mProcState;
 
-        ObserverCall(ObserverNode node, IContentObserver observer, boolean selfChange, int observerUserId) {
-            mNode = node;
+        ObserverCall(IContentObserver observer, boolean selfChange, Uri uri, int userId,
+                int procState) {
             mObserver = observer;
             mSelfChange = selfChange;
-            mObserverUserId = observerUserId;
+            mUri = uri;
+            mUserId = userId;
+            mProcState = procState;
+        }
+
+        @Override
+        public void run() {
+            try {
+                mObserver.onChange(mSelfChange, mUri, mUserId);
+                if (DEBUG) Slog.d(TAG, "Notified " + mObserver + " of update at " + mUri);
+            } catch (RemoteException ignored) {
+                // We already have a death observer that will clean up if the
+                // remote process dies
+            }
         }
     }
 
@@ -1345,11 +1353,8 @@
         return ContentResolver.SYNC_EXEMPTION_NONE;
     }
 
-    /**
-     * Hide this class since it is not part of api,
-     * but current unittest framework requires it to be public
-     * @hide
-     */
+    /** {@hide} */
+    @VisibleForTesting
     public static final class ObserverNode {
         private class ObserverEntry implements IBinder.DeathRecipient {
             public final IContentObserver observer;
@@ -1546,7 +1551,7 @@
             return false;
         }
 
-        private void collectMyObserversLocked(boolean leaf, IContentObserver observer,
+        private void collectMyObserversLocked(Uri uri, boolean leaf, IContentObserver observer,
                                               boolean observerWantsSelfNotifications, int flags,
                                               int targetUserHandle, ArrayList<ObserverCall> calls) {
             int N = mObservers.size();
@@ -1588,8 +1593,10 @@
                     if (DEBUG) Slog.d(TAG, "Reporting to " + entry.observer + ": leaf=" + leaf
                             + " flags=" + Integer.toHexString(flags)
                             + " desc=" + entry.notifyForDescendants);
-                    calls.add(new ObserverCall(this, entry.observer, selfChange,
-                            UserHandle.getUserId(entry.uid)));
+                    final int procState = LocalServices.getService(ActivityManagerInternal.class)
+                            .getUidProcessState(entry.uid);
+                    calls.add(new ObserverCall(entry.observer, selfChange, uri,
+                            targetUserHandle, procState));
                 }
             }
         }
@@ -1605,14 +1612,14 @@
             if (index >= segmentCount) {
                 // This is the leaf node, notify all observers
                 if (DEBUG) Slog.d(TAG, "Collecting leaf observers @ #" + index + ", node " + mName);
-                collectMyObserversLocked(true, observer, observerWantsSelfNotifications,
+                collectMyObserversLocked(uri, true, observer, observerWantsSelfNotifications,
                         flags, targetUserHandle, calls);
             } else if (index < segmentCount){
                 segment = getUriSegment(uri, index);
                 if (DEBUG) Slog.d(TAG, "Collecting non-leaf observers @ #" + index + " / "
                         + segment);
                 // Notify any observers at this level who are interested in descendants
-                collectMyObserversLocked(false, observer, observerWantsSelfNotifications,
+                collectMyObserversLocked(uri, false, observer, observerWantsSelfNotifications,
                         flags, targetUserHandle, calls);
             }
 
diff --git a/services/core/java/com/android/server/rollback/Rollback.java b/services/core/java/com/android/server/rollback/Rollback.java
index 0d5746b..6769fe0 100644
--- a/services/core/java/com/android/server/rollback/Rollback.java
+++ b/services/core/java/com/android/server/rollback/Rollback.java
@@ -65,7 +65,7 @@
     /**
      * The directory where the rollback data is stored.
      */
-    public final File backupDir;
+    private final File mBackupDir;
 
     /**
      * The time when the upgrade occurred, for purposes of expiring
@@ -74,24 +74,24 @@
      * The timestamp is not applicable for all rollback states, but we make
      * sure to keep it non-null to avoid potential errors there.
      */
-    public @NonNull Instant timestamp;
+    private @NonNull Instant mTimestamp;
 
     /**
      * The session ID for the staged session if this rollback data represents a staged session,
      * {@code -1} otherwise.
      */
-    public final int stagedSessionId;
+    private final int mStagedSessionId;
 
     /**
      * The current state of the rollback.
      * ENABLING, AVAILABLE, or COMMITTED.
      */
-    public @RollbackState int state;
+    private @RollbackState int mState;
 
     /**
      * The id of the post-reboot apk session for a staged install, if any.
      */
-    public int apkSessionId = -1;
+    private int mApkSessionId = -1;
 
     /**
      * True if we are expecting the package manager to call restoreUserData
@@ -99,7 +99,7 @@
      * has not yet been fully applied.
      */
     // NOTE: All accesses to this field are from the RollbackManager handler thread.
-    public boolean restoreUserDataInProgress = false;
+    private boolean mRestoreUserDataInProgress = false;
 
     /**
      * Constructs a new, empty Rollback instance.
@@ -114,10 +114,10 @@
                 /* isStaged */ stagedSessionId != -1,
                 /* causePackages */ new ArrayList<>(),
                 /* committedSessionId */ -1);
-        this.backupDir = backupDir;
-        this.stagedSessionId = stagedSessionId;
-        this.state = ROLLBACK_STATE_ENABLING;
-        this.timestamp = Instant.now();
+        mBackupDir = backupDir;
+        mStagedSessionId = stagedSessionId;
+        mState = ROLLBACK_STATE_ENABLING;
+        mTimestamp = Instant.now();
     }
 
     /**
@@ -126,21 +126,115 @@
     Rollback(RollbackInfo info, File backupDir, Instant timestamp, int stagedSessionId,
             @RollbackState int state, int apkSessionId, boolean restoreUserDataInProgress) {
         this.info = info;
-        this.backupDir = backupDir;
-        this.timestamp = timestamp;
-        this.stagedSessionId = stagedSessionId;
-        this.state = state;
-        this.apkSessionId = apkSessionId;
-        this.restoreUserDataInProgress = restoreUserDataInProgress;
+        mBackupDir = backupDir;
+        mTimestamp = timestamp;
+        mStagedSessionId = stagedSessionId;
+        mState = state;
+        mApkSessionId = apkSessionId;
+        mRestoreUserDataInProgress = restoreUserDataInProgress;
     }
 
     /**
      * Whether the rollback is for rollback of a staged install.
      */
-    public boolean isStaged() {
+    boolean isStaged() {
         return info.isStaged();
     }
 
+    /**
+     * Returns the directory in which rollback data should be stored.
+     */
+    File getBackupDir() {
+        return mBackupDir;
+    }
+
+    /**
+     * Returns the time when the upgrade occurred, for purposes of expiring rollback data.
+     */
+    Instant getTimestamp() {
+        return mTimestamp;
+    }
+
+    /**
+     * Sets the time at which upgrade occurred.
+     */
+    void setTimestamp(Instant timestamp) {
+        mTimestamp = timestamp;
+    }
+
+    /**
+     * Returns the session ID for the staged session if this rollback data represents a staged
+     * session, or {@code -1} otherwise.
+     */
+    int getStagedSessionId() {
+        return mStagedSessionId;
+    }
+
+    /**
+     * Returns true if the rollback is in the ENABLING state.
+     */
+    boolean isEnabling() {
+        return mState == ROLLBACK_STATE_ENABLING;
+    }
+
+    /**
+     * Returns true if the rollback is in the AVAILABLE state.
+     */
+    boolean isAvailable() {
+        return mState == ROLLBACK_STATE_AVAILABLE;
+    }
+
+    /**
+     * Returns true if the rollback is in the COMMITTED state.
+     */
+    boolean isCommitted() {
+        return mState == ROLLBACK_STATE_COMMITTED;
+    }
+
+    /**
+     * Sets the state of the rollback to AVAILABLE.
+     */
+    void setAvailable() {
+        mState = ROLLBACK_STATE_AVAILABLE;
+    }
+
+    /**
+     * Sets the state of the rollback to COMMITTED.
+     */
+    void setCommitted() {
+        mState = ROLLBACK_STATE_COMMITTED;
+    }
+
+    /**
+     * Returns the id of the post-reboot apk session for a staged install, if any.
+     */
+    int getApkSessionId() {
+        return mApkSessionId;
+    }
+
+    /**
+     * Sets the id of the post-reboot apk session for a staged install.
+     */
+    void setApkSessionId(int apkSessionId) {
+        mApkSessionId = apkSessionId;
+    }
+
+    /**
+     * Returns true if we are expecting the package manager to call restoreUserData for this
+     * rollback because it has just been committed but the rollback has not yet been fully applied.
+     */
+    boolean isRestoreUserDataInProgress() {
+        return mRestoreUserDataInProgress;
+    }
+
+    /**
+     * Sets whether we are expecting the package manager to call restoreUserData for this
+     * rollback because it has just been committed but the rollback has not yet been fully applied.
+     */
+    void setRestoreUserDataInProgress(boolean restoreUserDataInProgress) {
+        mRestoreUserDataInProgress = restoreUserDataInProgress;
+    }
+
     static String rollbackStateToString(@RollbackState int state) {
         switch (state) {
             case Rollback.ROLLBACK_STATE_ENABLING: return "enabling";
@@ -160,7 +254,7 @@
         throw new ParseException("Invalid rollback state: " + state, 0);
     }
 
-    public String getStateAsString() {
-        return rollbackStateToString(state);
+    String getStateAsString() {
+        return rollbackStateToString(mState);
     }
 }
diff --git a/services/core/java/com/android/server/rollback/RollbackManagerServiceImpl.java b/services/core/java/com/android/server/rollback/RollbackManagerServiceImpl.java
index 3147bc6..96d284b 100644
--- a/services/core/java/com/android/server/rollback/RollbackManagerServiceImpl.java
+++ b/services/core/java/com/android/server/rollback/RollbackManagerServiceImpl.java
@@ -282,7 +282,7 @@
             List<RollbackInfo> rollbacks = new ArrayList<>();
             for (int i = 0; i < mRollbacks.size(); ++i) {
                 Rollback rollback = mRollbacks.get(i);
-                if (rollback.state == Rollback.ROLLBACK_STATE_AVAILABLE) {
+                if (rollback.isAvailable()) {
                     rollbacks.add(rollback.info);
                 }
             }
@@ -298,7 +298,7 @@
             List<RollbackInfo> rollbacks = new ArrayList<>();
             for (int i = 0; i < mRollbacks.size(); ++i) {
                 Rollback rollback = mRollbacks.get(i);
-                if (rollback.state == Rollback.ROLLBACK_STATE_COMMITTED) {
+                if (rollback.isCommitted()) {
                     rollbacks.add(rollback.info);
                 }
             }
@@ -332,7 +332,7 @@
                     Iterator<Rollback> iter = mRollbacks.iterator();
                     while (iter.hasNext()) {
                         Rollback rollback = iter.next();
-                        rollback.timestamp = rollback.timestamp.plusMillis(timeDifference);
+                        rollback.setTimestamp(rollback.getTimestamp().plusMillis(timeDifference));
                         saveRollback(rollback);
                     }
                 }
@@ -358,7 +358,7 @@
         Slog.i(TAG, "Initiating rollback");
 
         Rollback rollback = getRollbackForId(rollbackId);
-        if (rollback == null || rollback.state != Rollback.ROLLBACK_STATE_AVAILABLE) {
+        if (rollback == null || !rollback.isAvailable()) {
             sendFailure(statusReceiver, RollbackManager.STATUS_FAILURE_ROLLBACK_UNAVAILABLE,
                     "Rollback unavailable");
             return;
@@ -454,8 +454,8 @@
                                     // TODO: Could this cause a rollback to be
                                     // resurrected if it should otherwise have
                                     // expired by now?
-                                    rollback.state = Rollback.ROLLBACK_STATE_AVAILABLE;
-                                    rollback.restoreUserDataInProgress = false;
+                                    rollback.setAvailable();
+                                    rollback.setRestoreUserDataInProgress(false);
                                 }
                                 sendFailure(statusReceiver, RollbackManager.STATUS_FAILURE_INSTALL,
                                         "Rollback downgrade install failed: "
@@ -468,7 +468,7 @@
                                 if (!rollback.isStaged()) {
                                     // All calls to restoreUserData should have
                                     // completed by now for a non-staged install.
-                                    rollback.restoreUserDataInProgress = false;
+                                    rollback.setRestoreUserDataInProgress(false);
                                 }
 
                                 rollback.info.setCommittedSessionId(parentSessionId);
@@ -490,8 +490,8 @@
             );
 
             synchronized (mLock) {
-                rollback.state = Rollback.ROLLBACK_STATE_COMMITTED;
-                rollback.restoreUserDataInProgress = true;
+                rollback.setCommitted();
+                rollback.setRestoreUserDataInProgress(true);
             }
             parentSession.commit(receiver.getIntentSender());
         } catch (IOException e) {
@@ -618,9 +618,9 @@
             synchronized (mLock) {
                 for (Rollback rollback : mRollbacks) {
                     if (rollback.isStaged()) {
-                        if (rollback.state == Rollback.ROLLBACK_STATE_ENABLING) {
+                        if (rollback.isEnabling()) {
                             enabling.add(rollback);
-                        } else if (rollback.restoreUserDataInProgress) {
+                        } else if (rollback.isRestoreUserDataInProgress()) {
                             restoreInProgress.add(rollback);
                         }
 
@@ -635,8 +635,8 @@
 
             for (Rollback rollback : enabling) {
                 PackageInstaller installer = mContext.getPackageManager().getPackageInstaller();
-                PackageInstaller.SessionInfo session = installer.getSessionInfo(
-                        rollback.stagedSessionId);
+                PackageInstaller.SessionInfo session =
+                        installer.getSessionInfo(rollback.getStagedSessionId());
                 if (session == null || session.isStagedSessionFailed()) {
                     // TODO: Do we need to remove this from
                     // mRollbacks, or is it okay to leave as
@@ -650,13 +650,13 @@
 
             for (Rollback rollback : restoreInProgress) {
                 PackageInstaller installer = mContext.getPackageManager().getPackageInstaller();
-                PackageInstaller.SessionInfo session = installer.getSessionInfo(
-                        rollback.stagedSessionId);
+                PackageInstaller.SessionInfo session =
+                        installer.getSessionInfo(rollback.getStagedSessionId());
                 // TODO: What if session is null?
                 if (session != null) {
                     if (session.isStagedSessionApplied() || session.isStagedSessionFailed()) {
                         synchronized (mLock) {
-                            rollback.restoreUserDataInProgress = false;
+                            rollback.setRestoreUserDataInProgress(false);
                         }
                         saveRollback(rollback);
                     }
@@ -694,8 +694,7 @@
             while (iter.hasNext()) {
                 Rollback rollback = iter.next();
                 // TODO: Should we remove rollbacks in the ENABLING state here?
-                if (rollback.state == Rollback.ROLLBACK_STATE_AVAILABLE
-                        || rollback.state == Rollback.ROLLBACK_STATE_ENABLING) {
+                if (rollback.isEnabling() || rollback.isAvailable()) {
                     for (PackageRollbackInfo info : rollback.info.getPackages()) {
                         if (info.getPackageName().equals(packageName)
                                 && !packageVersionsEqual(
@@ -761,15 +760,16 @@
             Iterator<Rollback> iter = mRollbacks.iterator();
             while (iter.hasNext()) {
                 Rollback rollback = iter.next();
-                if (rollback.state != Rollback.ROLLBACK_STATE_AVAILABLE) {
+                if (!rollback.isAvailable()) {
                     continue;
                 }
                 if (!now.isBefore(
-                            rollback.timestamp.plusMillis(mRollbackLifetimeDurationInMillis))) {
+                            rollback.getTimestamp()
+                                    .plusMillis(mRollbackLifetimeDurationInMillis))) {
                     iter.remove();
                     deleteRollback(rollback);
-                } else if (oldest == null || oldest.isAfter(rollback.timestamp)) {
-                    oldest = rollback.timestamp;
+                } else if (oldest == null || oldest.isAfter(rollback.getTimestamp())) {
+                    oldest = rollback.getTimestamp();
                 }
             }
         }
@@ -877,7 +877,7 @@
         synchronized (mLock) {
             for (int i = 0; i < mRollbacks.size(); ++i) {
                 Rollback rollback = mRollbacks.get(i);
-                if (rollback.apkSessionId == parentSession.getSessionId()) {
+                if (rollback.getApkSessionId() == parentSession.getSessionId()) {
                     // This is the apk session for a staged session with rollback enabled. We do not
                     // need to create a new rollback for this session.
                     return true;
@@ -1020,7 +1020,7 @@
             // staged installs
             for (int i = 0; i < mRollbacks.size(); i++) {
                 Rollback rollback = mRollbacks.get(i);
-                if (rollback.state != Rollback.ROLLBACK_STATE_ENABLING) {
+                if (!rollback.isEnabling()) {
                     continue;
                 }
 
@@ -1053,7 +1053,7 @@
         synchronized (mLock) {
             for (int i = 0; i < mRollbacks.size(); ++i) {
                 Rollback candidate = mRollbacks.get(i);
-                if (candidate.restoreUserDataInProgress) {
+                if (candidate.isRestoreUserDataInProgress()) {
                     info = getPackageRollbackInfo(candidate, packageName);
                     if (info != null) {
                         rollback = candidate;
@@ -1146,8 +1146,8 @@
             synchronized (mLock) {
                 for (int i = 0; i < mRollbacks.size(); ++i) {
                     Rollback candidate = mRollbacks.get(i);
-                    if (candidate.stagedSessionId == originalSessionId) {
-                        candidate.apkSessionId = apkSessionId;
+                    if (candidate.getStagedSessionId() == originalSessionId) {
+                        candidate.setApkSessionId(apkSessionId);
                         rollback = candidate;
                         break;
                     }
@@ -1333,8 +1333,8 @@
         // to a new package being installed. Won't this revive an expired
         // rollback? Consider adding a ROLLBACK_STATE_EXPIRED to address this.
         synchronized (mLock) {
-            rollback.state = Rollback.ROLLBACK_STATE_AVAILABLE;
-            rollback.timestamp = Instant.now();
+            rollback.setAvailable();
+            rollback.setTimestamp(Instant.now());
         }
         saveRollback(rollback);
 
@@ -1434,9 +1434,9 @@
                 ipw.println(info.getRollbackId() + ":");
                 ipw.increaseIndent();
                 ipw.println("-state: " + rollback.getStateAsString());
-                ipw.println("-timestamp: " + rollback.timestamp);
-                if (rollback.stagedSessionId != -1) {
-                    ipw.println("-stagedSessionId: " + rollback.stagedSessionId);
+                ipw.println("-timestamp: " + rollback.getTimestamp());
+                if (rollback.getStagedSessionId() != -1) {
+                    ipw.println("-stagedSessionId: " + rollback.getStagedSessionId());
                 }
                 ipw.println("-packages:");
                 ipw.increaseIndent();
@@ -1446,7 +1446,7 @@
                             + " -> " + pkg.getVersionRolledBackTo().getLongVersionCode());
                 }
                 ipw.decreaseIndent();
-                if (rollback.state == Rollback.ROLLBACK_STATE_COMMITTED) {
+                if (rollback.isCommitted()) {
                     ipw.println("-causePackages:");
                     ipw.increaseIndent();
                     for (VersionedPackage cPkg : info.getCausePackages()) {
diff --git a/services/core/java/com/android/server/rollback/RollbackStore.java b/services/core/java/com/android/server/rollback/RollbackStore.java
index b2448f6..772c53f 100644
--- a/services/core/java/com/android/server/rollback/RollbackStore.java
+++ b/services/core/java/com/android/server/rollback/RollbackStore.java
@@ -17,7 +17,6 @@
 package com.android.server.rollback;
 
 import static com.android.server.rollback.Rollback.rollbackStateFromString;
-import static com.android.server.rollback.Rollback.rollbackStateToString;
 
 import android.annotation.NonNull;
 import android.content.pm.VersionedPackage;
@@ -216,7 +215,7 @@
     static void backupPackageCodePath(Rollback rollback, String packageName, String codePath)
             throws IOException {
         File sourceFile = new File(codePath);
-        File targetDir = new File(rollback.backupDir, packageName);
+        File targetDir = new File(rollback.getBackupDir(), packageName);
         targetDir.mkdirs();
         File targetFile = new File(targetDir, sourceFile.getName());
 
@@ -229,7 +228,7 @@
      * Includes the base apk and any splits. Returns null if none found.
      */
     static File[] getPackageCodePaths(Rollback rollback, String packageName) {
-        File targetDir = new File(rollback.backupDir, packageName);
+        File targetDir = new File(rollback.getBackupDir(), packageName);
         File[] files = targetDir.listFiles();
         if (files == null || files.length == 0) {
             return null;
@@ -243,7 +242,7 @@
      */
     static void deletePackageCodePaths(Rollback rollback) {
         for (PackageRollbackInfo info : rollback.info.getPackages()) {
-            File targetDir = new File(rollback.backupDir, info.getPackageName());
+            File targetDir = new File(rollback.getBackupDir(), info.getPackageName());
             removeFile(targetDir);
         }
     }
@@ -255,13 +254,13 @@
         try {
             JSONObject dataJson = new JSONObject();
             dataJson.put("info", rollbackInfoToJson(rollback.info));
-            dataJson.put("timestamp", rollback.timestamp.toString());
-            dataJson.put("stagedSessionId", rollback.stagedSessionId);
-            dataJson.put("state", rollbackStateToString(rollback.state));
-            dataJson.put("apkSessionId", rollback.apkSessionId);
-            dataJson.put("restoreUserDataInProgress", rollback.restoreUserDataInProgress);
+            dataJson.put("timestamp", rollback.getTimestamp().toString());
+            dataJson.put("stagedSessionId", rollback.getStagedSessionId());
+            dataJson.put("state", rollback.getStateAsString());
+            dataJson.put("apkSessionId", rollback.getApkSessionId());
+            dataJson.put("restoreUserDataInProgress", rollback.isRestoreUserDataInProgress());
 
-            PrintWriter pw = new PrintWriter(new File(rollback.backupDir, "rollback.json"));
+            PrintWriter pw = new PrintWriter(new File(rollback.getBackupDir(), "rollback.json"));
             pw.println(dataJson.toString());
             pw.close();
         } catch (JSONException e) {
@@ -273,7 +272,7 @@
      * Removes all persistent storage associated with the given rollback.
      */
     void deleteRollback(Rollback rollback) {
-        removeFile(rollback.backupDir);
+        removeFile(rollback.getBackupDir());
     }
 
     /**
diff --git a/services/core/java/com/android/server/wm/DisplayPolicy.java b/services/core/java/com/android/server/wm/DisplayPolicy.java
index aecbca3..b502bd5 100644
--- a/services/core/java/com/android/server/wm/DisplayPolicy.java
+++ b/services/core/java/com/android/server/wm/DisplayPolicy.java
@@ -3551,7 +3551,8 @@
         if (mScreenshotHelper != null) {
             mScreenshotHelper.takeScreenshot(screenshotType,
                     mStatusBar != null && mStatusBar.isVisibleLw(),
-                    mNavigationBar != null && mNavigationBar.isVisibleLw(), mHandler);
+                    mNavigationBar != null && mNavigationBar.isVisibleLw(),
+                    mHandler, null /* completionConsumer */);
         }
     }
 
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/GlobalActionPerformerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/GlobalActionPerformerTest.java
index e72e460..c73be6f 100644
--- a/services/tests/servicestests/src/com/android/server/accessibility/GlobalActionPerformerTest.java
+++ b/services/tests/servicestests/src/com/android/server/accessibility/GlobalActionPerformerTest.java
@@ -35,6 +35,8 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.util.function.Consumer;
+
 /**
  * Tests for GlobalActionPerformer
  */
@@ -84,6 +86,6 @@
                 AccessibilityService.GLOBAL_ACTION_TAKE_SCREENSHOT);
         verify(mMockScreenshotHelper).takeScreenshot(
                 eq(android.view.WindowManager.TAKE_SCREENSHOT_FULLSCREEN), anyBoolean(),
-                anyBoolean(), any(Handler.class));
+                anyBoolean(), any(Handler.class), any());
     }
 }
diff --git a/services/tests/servicestests/src/com/android/server/rollback/RollbackUnitTest.java b/services/tests/servicestests/src/com/android/server/rollback/RollbackUnitTest.java
new file mode 100644
index 0000000..d27f1c7
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/rollback/RollbackUnitTest.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.rollback;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+
+@RunWith(JUnit4.class)
+public class RollbackUnitTest {
+
+    @Test
+    public void newEmptyStagedRollbackDefaults() {
+        int rollbackId = 123;
+        int sessionId = 567;
+        File file = new File("/test/testing");
+
+        Rollback rollback = new Rollback(rollbackId, file, sessionId);
+
+        assertThat(rollback.isEnabling()).isTrue();
+        assertThat(rollback.getBackupDir().getAbsolutePath()).isEqualTo("/test/testing");
+        assertThat(rollback.isStaged()).isTrue();
+        assertThat(rollback.getStagedSessionId()).isEqualTo(567);
+    }
+
+    @Test
+    public void newEmptyNonStagedRollbackDefaults() {
+        int rollbackId = 123;
+        File file = new File("/test/testing");
+
+        Rollback rollback = new Rollback(rollbackId, file, -1);
+
+        assertThat(rollback.isEnabling()).isTrue();
+        assertThat(rollback.getBackupDir().getAbsolutePath()).isEqualTo("/test/testing");
+        assertThat(rollback.isStaged()).isFalse();
+    }
+
+    @Test
+    public void rollbackStateChanges() {
+        Rollback rollback = new Rollback(123, new File("/test/testing"), -1);
+
+        assertThat(rollback.isEnabling()).isTrue();
+        assertThat(rollback.isAvailable()).isFalse();
+        assertThat(rollback.isCommitted()).isFalse();
+
+        rollback.setAvailable();
+
+        assertThat(rollback.isEnabling()).isFalse();
+        assertThat(rollback.isAvailable()).isTrue();
+        assertThat(rollback.isCommitted()).isFalse();
+
+        rollback.setCommitted();
+
+        assertThat(rollback.isEnabling()).isFalse();
+        assertThat(rollback.isAvailable()).isFalse();
+        assertThat(rollback.isCommitted()).isTrue();
+    }
+
+}
diff --git a/telephony/java/android/telephony/PhoneStateListener.java b/telephony/java/android/telephony/PhoneStateListener.java
index 271195b..6a3c06e 100644
--- a/telephony/java/android/telephony/PhoneStateListener.java
+++ b/telephony/java/android/telephony/PhoneStateListener.java
@@ -33,13 +33,13 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.telephony.IPhoneStateListener;
 
+import dalvik.system.VMRuntime;
+
 import java.lang.ref.WeakReference;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.Executor;
 
-import dalvik.system.VMRuntime;
-
 /**
  * A listener class for monitoring changes in specific telephony states
  * on the device, including service state, signal strength, message
@@ -301,11 +301,6 @@
      *  it could be the current active opportunistic subscription in use, or the
      *  subscription user selected as default data subscription in DSDS mode.
      *
-     *  Requires Permission: No permission is required to listen, but notification requires
-     *  {@link android.Manifest.permission#READ_PHONE_STATE READ_PHONE_STATE} or the calling
-     *  app has carrier privileges (see {@link TelephonyManager#hasCarrierPrivileges})
-     *  on any active subscription.
-     *
      *  @see #onActiveDataSubscriptionIdChanged
      */
     public static final int LISTEN_ACTIVE_DATA_SUBSCRIPTION_ID_CHANGE = 0x00400000;
diff --git a/tests/BootImageProfileTest/Android.bp b/tests/BootImageProfileTest/Android.bp
new file mode 100644
index 0000000..1b097a8
--- /dev/null
+++ b/tests/BootImageProfileTest/Android.bp
@@ -0,0 +1,20 @@
+// 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.
+
+java_test_host {
+    name: "BootImageProfileTest",
+    srcs: ["src/**/*.java"],
+    libs: ["tradefed"],
+    test_suites: ["general-tests"],
+}
diff --git a/tests/BootImageProfileTest/AndroidTest.xml b/tests/BootImageProfileTest/AndroidTest.xml
new file mode 100644
index 0000000..c132007
--- /dev/null
+++ b/tests/BootImageProfileTest/AndroidTest.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.
+-->
+<configuration description="Config for BootImageProfileTest">
+     <!-- do not use DeviceSetup#set-property because it reboots the device b/136200738.
+         furthermore the changes in /data/local.prop don't actually seem to get picked up.
+    -->
+    <target_preparer
+        class="com.android.tradefed.targetprep.DeviceSetup">
+        <!-- we need this magic flag, otherwise it always reboots and breaks the selinux -->
+        <option name="force-skip-system-props" value="true" />
+
+        <option name="run-command" value="setprop dalvik.vm.profilesystemserver true" />
+        <option name="run-command" value="setprop dalvik.vm.profilebootclasspath true" />
+
+        <!-- Profiling does not pick up the above changes we restart the shell -->
+        <option name="run-command" value="stop" />
+        <option name="run-command" value="start" />
+
+        <!-- give it some time to restart the shell; otherwise the first unit test might fail -->
+        <option name="run-command" value="sleep 2" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.HostTest" >
+        <option name="class" value="com.android.bootimageprofile.BootImageProfileTest" />
+    </test>
+</configuration>
diff --git a/tests/BootImageProfileTest/TEST_MAPPING b/tests/BootImageProfileTest/TEST_MAPPING
new file mode 100644
index 0000000..1b569f9
--- /dev/null
+++ b/tests/BootImageProfileTest/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "presubmit": [
+    {
+      "name": "BootImageProfileTest"
+    }
+  ]
+}
diff --git a/tests/BootImageProfileTest/src/com/android/bootimageprofile/BootImageProfileTest.java b/tests/BootImageProfileTest/src/com/android/bootimageprofile/BootImageProfileTest.java
new file mode 100644
index 0000000..17986a3
--- /dev/null
+++ b/tests/BootImageProfileTest/src/com/android/bootimageprofile/BootImageProfileTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.bootimageprofile;
+
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.IDeviceTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class BootImageProfileTest implements IDeviceTest {
+    private ITestDevice mTestDevice;
+    private static final String SYSTEM_SERVER_PROFILE =
+            "/data/misc/profiles/cur/0/android/primary.prof";
+
+    @Override
+    public void setDevice(ITestDevice testDevice) {
+        mTestDevice = testDevice;
+    }
+
+    @Override
+    public ITestDevice getDevice() {
+        return mTestDevice;
+    }
+
+    /**
+     * Test that the boot image profile properties are set.
+     */
+    @Test
+    public void testProperties() throws Exception {
+        String res = mTestDevice.getProperty("dalvik.vm.profilebootclasspath");
+        assertTrue("profile boot class path not enabled", res != null && res.equals("true"));
+        res = mTestDevice.getProperty("dalvik.vm.profilesystemserver");
+        assertTrue("profile system server not enabled", res != null && res.equals("true"));
+    }
+
+    private void forceSaveProfile(String pkg) throws Exception {
+        String pid = mTestDevice.executeShellCommand("pidof " + pkg).trim();
+        assertTrue("Invalid pid " + pid, pid.length() > 0);
+        String res = mTestDevice.executeShellCommand("kill -s SIGUSR1 " + pid).trim();
+        assertTrue("kill SIGUSR1: " + res, res.length() == 0);
+    }
+
+    @Test
+    public void testSystemServerProfile() throws Exception {
+        // Trunacte the profile before force it to be saved to prevent previous profiles
+        // causing the test to pass.
+        String res;
+        res = mTestDevice.executeShellCommand("truncate -s 0 " + SYSTEM_SERVER_PROFILE).trim();
+        assertTrue(res, res.length() == 0);
+        // Force save profiles in case the system just started.
+        Thread.sleep(1000);
+        forceSaveProfile("system_server");
+        Thread.sleep(2000);
+        // Validate that the profile is non empty.
+        res = mTestDevice.executeShellCommand("profman --dump-only --profile-file="
+                + SYSTEM_SERVER_PROFILE);
+        boolean sawFramework = false;
+        boolean sawServices = false;
+        for (String line : res.split("\n")) {
+            if (line.contains("framework.jar")) {
+                sawFramework = true;
+            } else if (line.contains("services.jar")) {
+                sawServices = true;
+            }
+        }
+        assertTrue("Did not see framework.jar in " + res, sawFramework);
+        assertTrue("Did not see services.jar in " + res, sawServices);
+    }
+}
diff --git a/tests/PackageWatchdog/Android.bp b/tests/PackageWatchdog/Android.bp
index 88d92c4..0b75039 100644
--- a/tests/PackageWatchdog/Android.bp
+++ b/tests/PackageWatchdog/Android.bp
@@ -23,6 +23,7 @@
         "androidx.test.rules",
         "services.core",
         "services.net",
+        "truth-prebuilt",
     ],
     libs: ["android.test.runner"],
     jni_libs: [
diff --git a/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java b/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
index 2d867f9..9a60330 100644
--- a/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
+++ b/tests/PackageWatchdog/src/com/android/server/PackageWatchdogTest.java
@@ -18,9 +18,8 @@
 
 import static android.service.watchdog.ExplicitHealthCheckService.PackageConfig;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
+
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -43,6 +42,7 @@
 
 import androidx.test.InstrumentationRegistry;
 
+import com.android.server.PackageWatchdog.HealthCheckState;
 import com.android.server.PackageWatchdog.MonitoredPackage;
 import com.android.server.PackageWatchdog.PackageHealthObserver;
 import com.android.server.PackageWatchdog.PackageHealthObserverImpact;
@@ -64,7 +64,6 @@
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
-// TODO: Use Truth in tests.
 /**
  * Test PackageWatchdog.
  */
@@ -118,13 +117,12 @@
         TestObserver observer = new TestObserver(OBSERVER_NAME_1);
 
         watchdog.startObservingHealth(observer, Arrays.asList(APP_A), SHORT_DURATION);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // The failed packages should be the same as the registered ones to ensure registration is
         // done successfully
-        assertEquals(1, observer.mHealthCheckFailedPackages.size());
-        assertTrue(observer.mHealthCheckFailedPackages.contains(APP_A));
+        assertThat(observer.mHealthCheckFailedPackages).containsExactly(APP_A);
     }
 
     @Test
@@ -135,17 +133,14 @@
 
         watchdog.startObservingHealth(observer1, Arrays.asList(APP_A), SHORT_DURATION);
         watchdog.startObservingHealth(observer2, Arrays.asList(APP_A, APP_B), SHORT_DURATION);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
-                new VersionedPackage(APP_B, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
+                        new VersionedPackage(APP_B, VERSION_CODE)));
 
         // The failed packages should be the same as the registered ones to ensure registration is
         // done successfully
-        assertEquals(1, observer1.mHealthCheckFailedPackages.size());
-        assertEquals(2, observer2.mHealthCheckFailedPackages.size());
-        assertTrue(observer1.mHealthCheckFailedPackages.contains(APP_A));
-        assertTrue(observer2.mHealthCheckFailedPackages.contains(APP_A));
-        assertTrue(observer2.mHealthCheckFailedPackages.contains(APP_B));
+        assertThat(observer1.mHealthCheckFailedPackages).containsExactly(APP_A);
+        assertThat(observer2.mHealthCheckFailedPackages).containsExactly(APP_A, APP_B);
     }
 
     @Test
@@ -155,11 +150,11 @@
 
         watchdog.startObservingHealth(observer, Arrays.asList(APP_A), SHORT_DURATION);
         watchdog.unregisterHealthObserver(observer);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // We should have no failed packages to ensure unregistration is done successfully
-        assertEquals(0, observer.mHealthCheckFailedPackages.size());
+        assertThat(observer.mHealthCheckFailedPackages).isEmpty();
     }
 
     @Test
@@ -171,13 +166,13 @@
         watchdog.startObservingHealth(observer1, Arrays.asList(APP_A), SHORT_DURATION);
         watchdog.startObservingHealth(observer2, Arrays.asList(APP_A), SHORT_DURATION);
         watchdog.unregisterHealthObserver(observer2);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // observer1 should receive failed packages as intended.
-        assertEquals(1, observer1.mHealthCheckFailedPackages.size());
+        assertThat(observer1.mHealthCheckFailedPackages).containsExactly(APP_A);
         // observer2 should have no failed packages to ensure unregistration is done successfully
-        assertEquals(0, observer2.mHealthCheckFailedPackages.size());
+        assertThat(observer2.mHealthCheckFailedPackages).isEmpty();
     }
 
     @Test
@@ -187,11 +182,11 @@
 
         watchdog.startObservingHealth(observer, Arrays.asList(APP_A), SHORT_DURATION);
         moveTimeForwardAndDispatch(SHORT_DURATION);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // We should have no failed packages for the fatal failure is raised after expiration
-        assertEquals(0, observer.mHealthCheckFailedPackages.size());
+        assertThat(observer.mHealthCheckFailedPackages).isEmpty();
     }
 
     @Test
@@ -203,13 +198,13 @@
         watchdog.startObservingHealth(observer1, Arrays.asList(APP_A), SHORT_DURATION);
         watchdog.startObservingHealth(observer2, Arrays.asList(APP_A), LONG_DURATION);
         moveTimeForwardAndDispatch(SHORT_DURATION);
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // We should have no failed packages for the fatal failure is raised after expiration
-        assertEquals(0, observer1.mHealthCheckFailedPackages.size());
+        assertThat(observer1.mHealthCheckFailedPackages).isEmpty();
         // We should have failed packages since observer2 hasn't expired
-        assertEquals(1, observer2.mHealthCheckFailedPackages.size());
+        assertThat(observer2.mHealthCheckFailedPackages).containsExactly(APP_A);
     }
 
     /** Observing already observed package extends the observation time. */
@@ -230,12 +225,11 @@
         // Then advance time such that it should have expired were it not for the second observation
         moveTimeForwardAndDispatch((SHORT_DURATION / 2) + 1);
 
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify that we receive failed packages as expected for APP_A not expired
-        assertEquals(1, observer.mHealthCheckFailedPackages.size());
-        assertTrue(observer.mHealthCheckFailedPackages.contains(APP_A));
+        assertThat(observer.mHealthCheckFailedPackages).containsExactly(APP_A);
     }
 
     /**
@@ -256,17 +250,14 @@
         // Then resume observer1 and observer2
         watchdog2.registerHealthObserver(observer1);
         watchdog2.registerHealthObserver(observer2);
-        raiseFatalFailure(watchdog2, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
-                new VersionedPackage(APP_B, VERSION_CODE)));
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog2,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
+                        new VersionedPackage(APP_B, VERSION_CODE)));
 
         // We should receive failed packages as expected to ensure observers are persisted and
         // resumed correctly
-        assertEquals(1, observer1.mHealthCheckFailedPackages.size());
-        assertEquals(2, observer2.mHealthCheckFailedPackages.size());
-        assertTrue(observer1.mHealthCheckFailedPackages.contains(APP_A));
-        assertTrue(observer1.mHealthCheckFailedPackages.contains(APP_A));
-        assertTrue(observer2.mHealthCheckFailedPackages.contains(APP_B));
+        assertThat(observer1.mHealthCheckFailedPackages).containsExactly(APP_A);
+        assertThat(observer2.mHealthCheckFailedPackages).containsExactly(APP_A, APP_B);
     }
 
     /**
@@ -290,8 +281,8 @@
         mTestLooper.dispatchAll();
 
         // Verify that observers are not notified
-        assertEquals(0, observer1.mMitigatedPackages.size());
-        assertEquals(0, observer2.mMitigatedPackages.size());
+        assertThat(observer1.mHealthCheckFailedPackages).isEmpty();
+        assertThat(observer2.mHealthCheckFailedPackages).isEmpty();
     }
 
     /**
@@ -309,14 +300,12 @@
         watchdog.startObservingHealth(observer1, Arrays.asList(APP_B), SHORT_DURATION);
 
         // Then fail APP_C (not observed) above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_C, VERSION_CODE)));
-
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_C, VERSION_CODE)));
 
         // Verify that observers are not notified
-        assertEquals(0, observer1.mMitigatedPackages.size());
-        assertEquals(0, observer2.mMitigatedPackages.size());
+        assertThat(observer1.mHealthCheckFailedPackages).isEmpty();
+        assertThat(observer2.mHealthCheckFailedPackages).isEmpty();
     }
 
     /**
@@ -341,14 +330,11 @@
         watchdog.startObservingHealth(observer, Arrays.asList(APP_A), SHORT_DURATION);
 
         // Then fail APP_A (different version) above the threshold
-        raiseFatalFailure(watchdog,
+        raiseFatalFailureAndDispatch(watchdog,
                 Arrays.asList(new VersionedPackage(APP_A, differentVersionCode)));
 
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
-
         // Verify that observers are not notified
-        assertEquals(0, observer.mMitigatedPackages.size());
+        assertThat(observer.mHealthCheckFailedPackages).isEmpty();
     }
 
 
@@ -378,13 +364,11 @@
                 SHORT_DURATION);
 
         // Then fail all apps above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
-                new VersionedPackage(APP_B, VERSION_CODE),
-                new VersionedPackage(APP_C, VERSION_CODE),
-                new VersionedPackage(APP_D, VERSION_CODE)));
-
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE),
+                        new VersionedPackage(APP_B, VERSION_CODE),
+                        new VersionedPackage(APP_C, VERSION_CODE),
+                        new VersionedPackage(APP_D, VERSION_CODE)));
 
         // Verify least impact observers are notifed of package failures
         List<String> observerNonePackages = observerNone.mMitigatedPackages;
@@ -393,16 +377,13 @@
         List<String> observerLowPackages = observerLow.mMitigatedPackages;
 
         // APP_D failure observed by only observerNone is not caught cos its impact is none
-        assertEquals(0, observerNonePackages.size());
+        assertThat(observerNonePackages).isEmpty();
         // APP_C failure is caught by observerHigh cos it's the lowest impact observer
-        assertEquals(1, observerHighPackages.size());
-        assertEquals(APP_C, observerHighPackages.get(0));
+        assertThat(observerHighPackages).containsExactly(APP_C);
         // APP_B failure is caught by observerMid cos it's the lowest impact observer
-        assertEquals(1, observerMidPackages.size());
-        assertEquals(APP_B, observerMidPackages.get(0));
+        assertThat(observerMidPackages).containsExactly(APP_B);
         // APP_A failure is caught by observerLow cos it's the lowest impact observer
-        assertEquals(1, observerLowPackages.size());
-        assertEquals(APP_A, observerLowPackages.get(0));
+        assertThat(observerLowPackages).containsExactly(APP_A);
     }
 
     /**
@@ -429,14 +410,12 @@
         watchdog.startObservingHealth(observerSecond, Arrays.asList(APP_A), LONG_DURATION);
 
         // Then fail APP_A above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify only observerFirst is notifed
-        assertEquals(1, observerFirst.mMitigatedPackages.size());
-        assertEquals(APP_A, observerFirst.mMitigatedPackages.get(0));
-        assertEquals(0, observerSecond.mMitigatedPackages.size());
+        assertThat(observerFirst.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
 
         // After observerFirst handles failure, next action it has is high impact
         observerFirst.mImpact = PackageHealthObserverImpact.USER_IMPACT_HIGH;
@@ -444,14 +423,12 @@
         observerSecond.mMitigatedPackages.clear();
 
         // Then fail APP_A again above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify only observerSecond is notifed cos it has least impact
-        assertEquals(1, observerSecond.mMitigatedPackages.size());
-        assertEquals(APP_A, observerSecond.mMitigatedPackages.get(0));
-        assertEquals(0, observerFirst.mMitigatedPackages.size());
+        assertThat(observerSecond.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerFirst.mMitigatedPackages).isEmpty();
 
         // After observerSecond handles failure, it has no further actions
         observerSecond.mImpact = PackageHealthObserverImpact.USER_IMPACT_NONE;
@@ -459,14 +436,12 @@
         observerSecond.mMitigatedPackages.clear();
 
         // Then fail APP_A again above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify only observerFirst is notifed cos it has the only action
-        assertEquals(1, observerFirst.mMitigatedPackages.size());
-        assertEquals(APP_A, observerFirst.mMitigatedPackages.get(0));
-        assertEquals(0, observerSecond.mMitigatedPackages.size());
+        assertThat(observerFirst.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
 
         // After observerFirst handles failure, it too has no further actions
         observerFirst.mImpact = PackageHealthObserverImpact.USER_IMPACT_NONE;
@@ -474,13 +449,12 @@
         observerSecond.mMitigatedPackages.clear();
 
         // Then fail APP_A again above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify no observer is notified cos no actions left
-        assertEquals(0, observerFirst.mMitigatedPackages.size());
-        assertEquals(0, observerSecond.mMitigatedPackages.size());
+        assertThat(observerFirst.mMitigatedPackages).isEmpty();
+        assertThat(observerSecond.mMitigatedPackages).isEmpty();
     }
 
     /**
@@ -499,15 +473,12 @@
         watchdog.startObservingHealth(observer1, Arrays.asList(APP_A), SHORT_DURATION);
 
         // Then fail APP_A above the threshold
-        raiseFatalFailure(watchdog, Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
-
-        // Run handler so package failures are dispatched to observers
-        mTestLooper.dispatchAll();
+        raiseFatalFailureAndDispatch(watchdog,
+                Arrays.asList(new VersionedPackage(APP_A, VERSION_CODE)));
 
         // Verify only one observer is notifed
-        assertEquals(1, observer1.mMitigatedPackages.size());
-        assertEquals(APP_A, observer1.mMitigatedPackages.get(0));
-        assertEquals(0, observer2.mMitigatedPackages.size());
+        assertThat(observer1.mMitigatedPackages).containsExactly(APP_A);
+        assertThat(observer2.mMitigatedPackages).isEmpty();
     }
 
     /**
@@ -536,9 +507,7 @@
 
         // Verify we requested health checks for APP_A and APP_B
         List<String> requestedPackages = controller.getRequestedPackages();
-        assertEquals(2, requestedPackages.size());
-        assertEquals(APP_A, requestedPackages.get(0));
-        assertEquals(APP_B, requestedPackages.get(1));
+        assertThat(requestedPackages).containsExactly(APP_A, APP_B);
 
         // Then health check passed for APP_A (observer1 is aware)
         controller.setPackagePassed(APP_A);
@@ -553,18 +522,16 @@
         moveTimeForwardAndDispatch(SHORT_DURATION);
 
         // Verify we cancelled all requests on expiry
-        assertEquals(0, controller.getRequestedPackages().size());
+        assertThat(controller.getRequestedPackages()).isEmpty();
 
         // Verify observer1 is not notified
-        assertEquals(0, observer1.mMitigatedPackages.size());
+        assertThat(observer1.mMitigatedPackages).isEmpty();
 
         // Verify observer2 is notifed because health checks for APP_B never passed
-        assertEquals(1, observer2.mMitigatedPackages.size());
-        assertEquals(APP_B, observer2.mMitigatedPackages.get(0));
+        assertThat(observer2.mMitigatedPackages).containsExactly(APP_B);
 
         // Verify observer3 is notifed because health checks for APP_A did not pass before expiry
-        assertEquals(1, observer3.mMitigatedPackages.size());
-        assertEquals(APP_A, observer3.mMitigatedPackages.get(0));
+        assertThat(observer3.mMitigatedPackages).containsExactly(APP_A);
     }
 
     /**
@@ -591,9 +558,7 @@
 
         // Verify we requested health checks for APP_A and APP_B
         List<String> requestedPackages = controller.getRequestedPackages();
-        assertEquals(2, requestedPackages.size());
-        assertEquals(APP_A, requestedPackages.get(0));
-        assertEquals(APP_B, requestedPackages.get(1));
+        assertThat(requestedPackages).containsExactly(APP_A, APP_B);
 
         // Disable explicit health checks (marks APP_A and APP_B as passed)
         setExplicitHealthCheckEnabled(false);
@@ -602,13 +567,13 @@
         mTestLooper.dispatchAll();
 
         // Verify all checks are cancelled
-        assertEquals(0, controller.getRequestedPackages().size());
+        assertThat(controller.getRequestedPackages()).isEmpty();
 
         // Then expire APP_A
         moveTimeForwardAndDispatch(SHORT_DURATION);
 
         // Verify APP_A is not failed (APP_B) is not expired yet
-        assertEquals(0, observer.mMitigatedPackages.size());
+        assertThat(observer.mMitigatedPackages).isEmpty();
 
         // Re-enable explicit health checks
         setExplicitHealthCheckEnabled(true);
@@ -617,7 +582,7 @@
         mTestLooper.dispatchAll();
 
         // Verify no requests are made cos APP_A is expired and APP_B was marked as passed
-        assertEquals(0, controller.getRequestedPackages().size());
+        assertThat(controller.getRequestedPackages()).isEmpty();
 
         // Then set new supported packages
         controller.setSupportedPackages(Arrays.asList(APP_C));
@@ -629,15 +594,13 @@
 
         // Verify requests are only made for APP_C
         requestedPackages = controller.getRequestedPackages();
-        assertEquals(1, requestedPackages.size());
-        assertEquals(APP_C, requestedPackages.get(0));
+        assertThat(requestedPackages).containsExactly(APP_C);
 
         // Then expire APP_A and APP_C
         moveTimeForwardAndDispatch(SHORT_DURATION);
 
         // Verify only APP_C is failed because explicit health checks was not supported for APP_A
-        assertEquals(1, observer.mMitigatedPackages.size());
-        assertEquals(APP_C, observer.mMitigatedPackages.get(0));
+        assertThat(observer.mMitigatedPackages).containsExactly(APP_C);
     }
 
     /**
@@ -661,8 +624,7 @@
         moveTimeForwardAndDispatch(SHORT_DURATION);
 
         // Verify that health check is failed
-        assertEquals(1, observer.mMitigatedPackages.size());
-        assertEquals(APP_A, observer.mMitigatedPackages.get(0));
+        assertThat(observer.mMitigatedPackages).containsExactly(APP_A);
 
         // Then clear failed packages and start observing a random package so requests are synced
         // and PackageWatchdog#onSupportedPackages is called and APP_A has a chance to fail again
@@ -671,7 +633,7 @@
         watchdog.startObservingHealth(observer, Arrays.asList(APP_B), LONG_DURATION);
 
         // Verify that health check failure is not notified again
-        assertTrue(observer.mMitigatedPackages.isEmpty());
+        assertThat(observer.mMitigatedPackages).isEmpty();
     }
 
     /** Tests {@link MonitoredPackage} health check state transitions. */
@@ -687,36 +649,38 @@
 
         // Verify transition: inactive -> active -> passed
         // Verify initially inactive
-        assertEquals(MonitoredPackage.STATE_INACTIVE, m1.getHealthCheckStateLocked());
+        assertThat(m1.getHealthCheckStateLocked()).isEqualTo(HealthCheckState.INACTIVE);
         // Verify still inactive, until we #setHealthCheckActiveLocked
-        assertEquals(MonitoredPackage.STATE_INACTIVE, m1.handleElapsedTimeLocked(SHORT_DURATION));
+        assertThat(m1.handleElapsedTimeLocked(SHORT_DURATION)).isEqualTo(HealthCheckState.INACTIVE);
         // Verify now active
-        assertEquals(MonitoredPackage.STATE_ACTIVE, m1.setHealthCheckActiveLocked(SHORT_DURATION));
+        assertThat(m1.setHealthCheckActiveLocked(SHORT_DURATION)).isEqualTo(
+                HealthCheckState.ACTIVE);
         // Verify now passed
-        assertEquals(MonitoredPackage.STATE_PASSED, m1.tryPassHealthCheckLocked());
+        assertThat(m1.tryPassHealthCheckLocked()).isEqualTo(HealthCheckState.PASSED);
 
         // Verify transition: inactive -> active -> failed
         // Verify initially inactive
-        assertEquals(MonitoredPackage.STATE_INACTIVE, m2.getHealthCheckStateLocked());
+        assertThat(m2.getHealthCheckStateLocked()).isEqualTo(HealthCheckState.INACTIVE);
         // Verify now active
-        assertEquals(MonitoredPackage.STATE_ACTIVE, m2.setHealthCheckActiveLocked(SHORT_DURATION));
+        assertThat(m2.setHealthCheckActiveLocked(SHORT_DURATION)).isEqualTo(
+                HealthCheckState.ACTIVE);
         // Verify now failed
-        assertEquals(MonitoredPackage.STATE_FAILED, m2.handleElapsedTimeLocked(SHORT_DURATION));
+        assertThat(m2.handleElapsedTimeLocked(SHORT_DURATION)).isEqualTo(HealthCheckState.FAILED);
 
         // Verify transition: inactive -> failed
         // Verify initially inactive
-        assertEquals(MonitoredPackage.STATE_INACTIVE, m3.getHealthCheckStateLocked());
+        assertThat(m3.getHealthCheckStateLocked()).isEqualTo(HealthCheckState.INACTIVE);
         // Verify now failed because package expired
-        assertEquals(MonitoredPackage.STATE_FAILED, m3.handleElapsedTimeLocked(LONG_DURATION));
+        assertThat(m3.handleElapsedTimeLocked(LONG_DURATION)).isEqualTo(HealthCheckState.FAILED);
         // Verify remains failed even when asked to pass
-        assertEquals(MonitoredPackage.STATE_FAILED, m3.tryPassHealthCheckLocked());
+        assertThat(m3.tryPassHealthCheckLocked()).isEqualTo(HealthCheckState.FAILED);
 
         // Verify transition: passed
-        assertEquals(MonitoredPackage.STATE_PASSED, m4.getHealthCheckStateLocked());
+        assertThat(m4.getHealthCheckStateLocked()).isEqualTo(HealthCheckState.PASSED);
         // Verify remains passed even if health check fails
-        assertEquals(MonitoredPackage.STATE_PASSED, m4.handleElapsedTimeLocked(SHORT_DURATION));
+        assertThat(m4.handleElapsedTimeLocked(SHORT_DURATION)).isEqualTo(HealthCheckState.PASSED);
         // Verify remains passed even if package expires
-        assertEquals(MonitoredPackage.STATE_PASSED, m4.handleElapsedTimeLocked(LONG_DURATION));
+        assertThat(m4.handleElapsedTimeLocked(LONG_DURATION)).isEqualTo(HealthCheckState.PASSED);
     }
 
     @Test
@@ -735,8 +699,7 @@
         mTestLooper.dispatchAll();
 
         // Verify the NetworkStack observer is notified
-        assertEquals(1, observer.mMitigatedPackages.size());
-        assertEquals(APP_A, observer.mMitigatedPackages.get(0));
+        assertThat(observer.mMitigatedPackages).containsExactly(APP_A);
     }
 
     private void adoptShellPermissions(String... permissions) {
@@ -772,10 +735,12 @@
     }
 
     /** Trigger package failures above the threshold. */
-    private void raiseFatalFailure(PackageWatchdog watchdog, List<VersionedPackage> packages) {
+    private void raiseFatalFailureAndDispatch(PackageWatchdog watchdog,
+            List<VersionedPackage> packages) {
         for (int i = 0; i < watchdog.getTriggerFailureCount(); i++) {
             watchdog.onPackageFailure(packages);
         }
+        mTestLooper.dispatchAll();
     }
 
     private PackageWatchdog createWatchdog() {
@@ -790,13 +755,13 @@
                 new PackageWatchdog(mSpyContext, policyFile, handler, handler, controller,
                         mConnectivityModuleConnector, mTestClock);
         // Verify controller is not automatically started
-        assertFalse(controller.mIsEnabled);
+        assertThat(controller.mIsEnabled).isFalse();
         if (withPackagesReady) {
             // Only capture the NetworkStack callback for the latest registered watchdog
             reset(mConnectivityModuleConnector);
             watchdog.onPackagesReady();
             // Verify controller by default is started when packages are ready
-            assertTrue(controller.mIsEnabled);
+            assertThat(controller.mIsEnabled).isTrue();
 
             verify(mConnectivityModuleConnector).registerHealthListener(
                     mConnectivityModuleCallbackCaptor.capture());
diff --git a/tests/RollbackTest/Android.bp b/tests/RollbackTest/Android.bp
index 2bd5931..231d045b 100644
--- a/tests/RollbackTest/Android.bp
+++ b/tests/RollbackTest/Android.bp
@@ -31,9 +31,9 @@
 }
 
 java_test_host {
-    name: "SecondaryUserRollbackTest",
-    srcs: ["SecondaryUserRollbackTest/src/**/*.java"],
+    name: "MultiUserRollbackTest",
+    srcs: ["MultiUserRollbackTest/src/**/*.java"],
     libs: ["tradefed"],
     test_suites: ["general-tests"],
-    test_config: "SecondaryUserRollbackTest.xml",
+    test_config: "MultiUserRollbackTest.xml",
 }
diff --git a/tests/RollbackTest/SecondaryUserRollbackTest.xml b/tests/RollbackTest/MultiUserRollbackTest.xml
similarity index 67%
rename from tests/RollbackTest/SecondaryUserRollbackTest.xml
rename to tests/RollbackTest/MultiUserRollbackTest.xml
index 6b3f05c..41cec46 100644
--- a/tests/RollbackTest/SecondaryUserRollbackTest.xml
+++ b/tests/RollbackTest/MultiUserRollbackTest.xml
@@ -13,17 +13,12 @@
      See the License for the specific language governing permissions and
      limitations under the License.
 -->
-<configuration description="Runs the rollback test from a secondary user">
-    <option name="test-suite-tag" value="SecondaryUserRollbackTest" />
-    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
-        <option name="cleanup-apks" value="true" />
-        <option name="test-file-name" value="RollbackTest.apk" />
-    </target_preparer>
+<configuration description="Runs rollback tests for multiple users">
+    <option name="test-suite-tag" value="MultiUserRollbackTest" />
     <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
         <option name="run-command" value="pm uninstall com.android.cts.install.lib.testapp.A" />
-        <option name="run-command" value="pm uninstall com.android.cts.install.lib.testapp.B" />
     </target_preparer>
     <test class="com.android.tradefed.testtype.HostTest" >
-        <option name="class" value="com.android.tests.rollback.host.SecondaryUserRollbackTest" />
+        <option name="class" value="com.android.tests.rollback.host.MultiUserRollbackTest" />
     </test>
 </configuration>
diff --git a/tests/RollbackTest/MultiUserRollbackTest/src/com/android/tests/rollback/host/MultiUserRollbackTest.java b/tests/RollbackTest/MultiUserRollbackTest/src/com/android/tests/rollback/host/MultiUserRollbackTest.java
new file mode 100644
index 0000000..52f6eba
--- /dev/null
+++ b/tests/RollbackTest/MultiUserRollbackTest/src/com/android/tests/rollback/host/MultiUserRollbackTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tests.rollback.host;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Runs rollback tests for multiple users.
+ */
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class MultiUserRollbackTest extends BaseHostJUnit4Test {
+    // The user that was running originally when the test starts.
+    private int mOriginalUserId;
+    private int mSecondaryUserId = -1;
+    private static final long SWITCH_USER_COMPLETED_NUMBER_OF_POLLS = 60;
+    private static final long SWITCH_USER_COMPLETED_POLL_INTERVAL_IN_MILLIS = 1000;
+
+
+    @After
+    public void tearDown() throws Exception {
+        getDevice().switchUser(mOriginalUserId);
+        getDevice().executeShellCommand("pm uninstall com.android.cts.install.lib.testapp.A");
+        removeSecondaryUserIfNecessary();
+    }
+
+    @Before
+    public void setup() throws Exception {
+        mOriginalUserId = getDevice().getCurrentUser();
+        installPackageAsUser("RollbackTest.apk", true, mOriginalUserId);
+        createAndSwitchToSecondaryUserIfNecessary();
+        installPackageAsUser("RollbackTest.apk", true, mSecondaryUserId);
+    }
+
+    @Test
+    public void testBasicForSecondaryUser() throws Exception {
+        runPhaseForUsers("testBasic", mSecondaryUserId);
+    }
+
+    @Test
+    public void testMultipleUsers() throws Exception {
+        runPhaseForUsers("testMultipleUsersInstallV1", mOriginalUserId, mSecondaryUserId);
+        runPhaseForUsers("testMultipleUsersUpgradeToV2", mOriginalUserId);
+        runPhaseForUsers("testMultipleUsersUpdateUserData", mOriginalUserId, mSecondaryUserId);
+        switchToUser(mOriginalUserId);
+        getDevice().executeShellCommand("pm rollback-app com.android.cts.install.lib.testapp.A");
+        runPhaseForUsers("testMultipleUsersVerifyUserdataRollback", mOriginalUserId,
+                mSecondaryUserId);
+    }
+
+    /**
+     * Run the phase for the given user ids, in the order they are given.
+     */
+    private void runPhaseForUsers(String phase, int... userIds) throws Exception {
+        for (int userId: userIds) {
+            switchToUser(userId);
+            assertTrue(runDeviceTests("com.android.tests.rollback",
+                    "com.android.tests.rollback.MultiUserRollbackTest",
+                    phase));
+        }
+    }
+
+    private void removeSecondaryUserIfNecessary() throws Exception {
+        if (mSecondaryUserId != -1) {
+            getDevice().removeUser(mSecondaryUserId);
+            mSecondaryUserId = -1;
+        }
+    }
+
+    private void createAndSwitchToSecondaryUserIfNecessary() throws Exception {
+        if (mSecondaryUserId == -1) {
+            mOriginalUserId = getDevice().getCurrentUser();
+            mSecondaryUserId = getDevice().createUser("MultiUserRollbackTest_User"
+                    + System.currentTimeMillis());
+            switchToUser(mSecondaryUserId);
+        }
+    }
+
+    private void switchToUser(int userId) throws Exception {
+        if (getDevice().getCurrentUser() == userId) {
+            return;
+        }
+
+        assertTrue(getDevice().switchUser(userId));
+        for (int i = 0; i < SWITCH_USER_COMPLETED_NUMBER_OF_POLLS; ++i) {
+            String userState = getDevice().executeShellCommand("am get-started-user-state "
+                    + userId);
+            if (userState.contains("RUNNING_UNLOCKED")) {
+                return;
+            }
+            Thread.sleep(SWITCH_USER_COMPLETED_POLL_INTERVAL_IN_MILLIS);
+        }
+        fail("User switch to user " + userId + " timed out");
+    }
+}
diff --git a/tests/RollbackTest/RollbackTest.xml b/tests/RollbackTest/RollbackTest.xml
index 70cd867..a14b01c 100644
--- a/tests/RollbackTest/RollbackTest.xml
+++ b/tests/RollbackTest/RollbackTest.xml
@@ -22,8 +22,9 @@
         <option name="package" value="com.android.tests.rollback" />
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
 
-        <!-- Exclude the StagedRollbackTest tests, which needs to be specially
-             driven from the StagedRollbackTest host test -->
+        <!-- Exclude the StagedRollbackTest and MultiUserRollbackTest tests, which need to be
+             specially driven from the StagedRollbackTest and MultiUserRollbackTest host test -->
         <option name="exclude-filter" value="com.android.tests.rollback.StagedRollbackTest" />
+        <option name="exclude-filter" value="com.android.tests.rollback.MultiUserRollbackTest" />
     </test>
 </configuration>
diff --git a/tests/RollbackTest/RollbackTest/src/com/android/tests/rollback/MultiUserRollbackTest.java b/tests/RollbackTest/RollbackTest/src/com/android/tests/rollback/MultiUserRollbackTest.java
new file mode 100644
index 0000000..0ffe041
--- /dev/null
+++ b/tests/RollbackTest/RollbackTest/src/com/android/tests/rollback/MultiUserRollbackTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tests.rollback;
+
+import static com.android.cts.rollback.lib.RollbackInfoSubject.assertThat;
+import static com.android.cts.rollback.lib.RollbackUtils.getUniqueRollbackInfoForPackage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.Manifest;
+import android.content.rollback.RollbackInfo;
+import android.content.rollback.RollbackManager;
+
+import com.android.cts.install.lib.Install;
+import com.android.cts.install.lib.InstallUtils;
+import com.android.cts.install.lib.TestApp;
+import com.android.cts.rollback.lib.Rollback;
+import com.android.cts.rollback.lib.RollbackUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+
+@RunWith(JUnit4.class)
+public class MultiUserRollbackTest {
+
+    @Before
+    public void adoptShellPermissions() {
+        InstallUtils.adoptShellPermissionIdentity(
+                Manifest.permission.INSTALL_PACKAGES,
+                Manifest.permission.DELETE_PACKAGES,
+                Manifest.permission.TEST_MANAGE_ROLLBACKS,
+                Manifest.permission.MANAGE_ROLLBACKS);
+    }
+
+    @After
+    public void dropShellPermissions() {
+        InstallUtils.dropShellPermissionIdentity();
+    }
+
+    @Test
+    public void testBasic() throws Exception {
+        new RollbackTest().testBasic();
+    }
+
+    /**
+     * Install version 1 of the test app. This method is run for both users.
+     */
+    @Test
+    public void testMultipleUsersInstallV1() throws Exception {
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(-1);
+        Install.single(TestApp.A1).commit();
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(1);
+        InstallUtils.processUserData(TestApp.A);
+    }
+
+    /**
+     * Upgrade the test app to version 2. This method should only run once as the system user,
+     * and will update the app for both users.
+     */
+    @Test
+    public void testMultipleUsersUpgradeToV2() throws Exception {
+        RollbackManager rm = RollbackUtils.getRollbackManager();
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(1);
+        Install.single(TestApp.A2).setEnableRollback().commit();
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(2);
+        RollbackInfo rollback = getUniqueRollbackInfoForPackage(
+                rm.getAvailableRollbacks(), TestApp.A);
+        assertThat(rollback).isNotNull();
+        assertThat(rollback).packagesContainsExactly(
+                Rollback.from(TestApp.A2).to(TestApp.A1));
+    }
+
+    /**
+     * This method is run for both users. Assert that the test app has upgraded for both users, and
+     * update their userdata to reflect this new version.
+     */
+    @Test
+    public void testMultipleUsersUpdateUserData() {
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(2);
+        InstallUtils.processUserData(TestApp.A);
+    }
+
+    /**
+     * The system will have rolled back the test app at this stage. Verify that the rollback has
+     * taken place, and that the userdata has been correctly rolled back. This method is run for
+     * both users.
+     */
+    @Test
+    public void testMultipleUsersVerifyUserdataRollback() {
+        assertThat(InstallUtils.getInstalledVersion(TestApp.A)).isEqualTo(1);
+        InstallUtils.processUserData(TestApp.A);
+    }
+}
diff --git a/tests/RollbackTest/SecondaryUserRollbackTest/src/com/android/tests/rollback/host/SecondaryUserRollbackTest.java b/tests/RollbackTest/SecondaryUserRollbackTest/src/com/android/tests/rollback/host/SecondaryUserRollbackTest.java
deleted file mode 100644
index 11a0fbb..0000000
--- a/tests/RollbackTest/SecondaryUserRollbackTest/src/com/android/tests/rollback/host/SecondaryUserRollbackTest.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tests.rollback.host;
-
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
-import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/**
- * Runs rollback tests from a secondary user.
- */
-@RunWith(DeviceJUnit4ClassRunner.class)
-public class SecondaryUserRollbackTest extends BaseHostJUnit4Test {
-    private static final int SYSTEM_USER_ID = 0;
-    // The user that was running originally when the test starts.
-    private int mOriginalUser = SYSTEM_USER_ID;
-    private int mSecondaryUserId = -1;
-    private static final long SWITCH_USER_COMPLETED_NUMBER_OF_POLLS = 60;
-    private static final long SWITCH_USER_COMPLETED_POLL_INTERVAL_IN_MILLIS = 1000;
-
-
-    @After
-    public void tearDown() throws Exception {
-        getDevice().switchUser(mOriginalUser);
-        getDevice().executeShellCommand("pm uninstall com.android.cts.install.lib.testapp.A");
-        getDevice().executeShellCommand("pm uninstall com.android.cts.install.lib.testapp.B");
-        removeSecondaryUserIfNecessary();
-    }
-
-    @Before
-    public void setup() throws Exception {
-        createAndSwitchToSecondaryUserIfNecessary();
-        installPackageAsUser("RollbackTest.apk", true, mSecondaryUserId, "--user current");
-    }
-
-    @Test
-    public void testBasic() throws Exception {
-        assertTrue(runDeviceTests("com.android.tests.rollback",
-                "com.android.tests.rollback.RollbackTest",
-                "testBasic"));
-    }
-
-    private void removeSecondaryUserIfNecessary() throws Exception {
-        if (mSecondaryUserId != -1) {
-            getDevice().removeUser(mSecondaryUserId);
-            mSecondaryUserId = -1;
-        }
-    }
-
-    private void createAndSwitchToSecondaryUserIfNecessary() throws Exception {
-        if (mSecondaryUserId == -1) {
-            mOriginalUser = getDevice().getCurrentUser();
-            mSecondaryUserId = getDevice().createUser("SecondaryUserRollbackTest_User");
-            assertTrue(getDevice().switchUser(mSecondaryUserId));
-            // give time for user to be switched
-            waitForSwitchUserCompleted(mSecondaryUserId);
-        }
-    }
-
-    private void waitForSwitchUserCompleted(int userId) throws Exception {
-        for (int i = 0; i < SWITCH_USER_COMPLETED_NUMBER_OF_POLLS; ++i) {
-            String logs = getDevice().executeAdbCommand("logcat", "-v", "brief", "-d",
-                    "ActivityManager:D");
-            if (logs.contains("Posting BOOT_COMPLETED user #" + userId)) {
-                return;
-            }
-            Thread.sleep(SWITCH_USER_COMPLETED_POLL_INTERVAL_IN_MILLIS);
-        }
-        fail("User switch to user " + userId + " timed out");
-    }
-}
diff --git a/tests/RollbackTest/TEST_MAPPING b/tests/RollbackTest/TEST_MAPPING
index 7ae03e6..fefde5b 100644
--- a/tests/RollbackTest/TEST_MAPPING
+++ b/tests/RollbackTest/TEST_MAPPING
@@ -7,7 +7,7 @@
       "name": "StagedRollbackTest"
     },
     {
-      "name": "SecondaryUserRollbackTest"
+      "name": "MultiUserRollbackTest"
     }
   ]
 }
diff --git a/tools/protologtool/Android.bp b/tools/protologtool/Android.bp
new file mode 100644
index 0000000..a86c226
--- /dev/null
+++ b/tools/protologtool/Android.bp
@@ -0,0 +1,28 @@
+java_binary_host {
+    name: "protologtool",
+    manifest: "manifest.txt",
+    srcs: [
+        "src/**/*.kt",
+    ],
+    static_libs: [
+        "javaparser",
+        "windowmanager-log-proto",
+        "jsonlib",
+    ],
+}
+
+java_test_host {
+    name: "protologtool-tests",
+    test_suites: ["general-tests"],
+    srcs: [
+        "src/**/*.kt",
+        "tests/**/*.kt",
+    ],
+    static_libs: [
+        "javaparser",
+        "windowmanager-log-proto",
+        "jsonlib",
+        "junit",
+        "mockito",
+    ],
+}
diff --git a/tools/protologtool/README.md b/tools/protologtool/README.md
new file mode 100644
index 0000000..3439357
--- /dev/null
+++ b/tools/protologtool/README.md
@@ -0,0 +1,106 @@
+# ProtoLogTool
+
+Code transformation tool and viewer for ProtoLog.
+
+## What does it do?
+
+ProtoLogTool incorporates three different modes of operation:
+
+### Code transformation
+
+Command: `process <protolog class path> <protolog implementation class path>
+ <protolog groups class path> <config.jar> [<input.java>] <output.srcjar>`
+
+In this mode ProtoLogTool transforms every ProtoLog logging call in form of:
+```java
+ProtoLog.x(ProtoLogGroup.GROUP_NAME, "Format string %d %s", value1, value2);
+```
+into:
+```java
+if (GROUP_NAME.isLogToAny()) {
+    ProtoLogImpl.x(ProtoLogGroup.GROUP_NAME, 123456, "Format string %d %s or null", value1, value2);
+}
+```
+where `ProtoLog`, `ProtoLogImpl` and `ProtoLogGroup` are the classes provided as arguments
+ (can be imported, static imported or full path, wildcard imports are not allowed) and, `x` is the
+ logging method. The transformation is done on the source level. A hash is generated from the format
+ string and log level and inserted after the `ProtoLogGroup` argument. The format string is replaced
+ by `null` if `ProtoLogGroup.GROUP_NAME.isLogToLogcat()` returns false. If `ProtoLogGroup.GROUP_NAME.isEnabled()`
+ returns false the log statement is removed entirely from the resultant code.
+
+Input is provided as a list of java source file names. Transformed source is saved to a single
+source jar file. The ProtoLogGroup class with all dependencies should be provided as a compiled
+jar file (config.jar).
+
+### Viewer config generation
+
+Command: `viewerconf <protolog class path> <protolog implementation class path
+<protolog groups class path> <config.jar> [<input.java>] <output.json>`
+
+This command is similar in it's syntax to the previous one, only instead of creating a processed source jar
+it writes a viewer configuration file with following schema:
+```json
+{
+  "version": "1.0.0",
+  "messages": {
+    "123456": {
+      "message": "Format string %d %s",
+      "level": "ERROR",
+      "group": "GROUP_NAME"
+    },
+  },
+  "groups": {
+    "GROUP_NAME": {
+      "tag": "TestLog"
+    }
+  }
+}
+
+```
+
+### Binary log viewing
+
+Command: `read <viewer.json> <wm_log.pb>`
+
+Reads the binary ProtoLog log file and outputs a human-readable LogCat-like text log.
+
+## What is ProtoLog?
+
+ProtoLog is a logging system created for the WindowManager project. It allows both binary and text logging
+and is tunable in runtime. It consists of 3 different submodules:
+* logging system built-in the Android app,
+* log viewer for reading binary logs,
+* a code processing tool.
+
+ProtoLog is designed to reduce both application size (and by that memory usage) and amount of resources needed
+for logging. This is achieved by replacing log message strings with their hashes and only loading to memory/writing
+full log messages when necessary.
+
+### Text logging
+
+For text-based logs Android LogCat is used as a backend. Message strings are loaded from a viewer config
+located on the device when needed.
+
+### Binary logging
+
+Binary logs are saved as Protocol Buffers file. They can be read using the ProtoLog tool or specialised
+viewer like Winscope.
+
+## How to use ProtoLog?
+
+### Adding a new logging group or log statement
+
+To add a new ProtoLogGroup simple create a new enum ProtoLogGroup member with desired parameters.
+
+To add a new logging statement just add a new call to ProtoLog.x where x is a log level.
+
+After doing any changes to logging groups or statements you should run `make update-protolog` to update
+viewer configuration saved in the code repository.
+
+## How to change settings on device in runtime?
+Use the `adb shell su root cmd window logging` command. To get help just type
+`adb shell su root cmd window logging help`.
+
+
+
+
diff --git a/tools/protologtool/TEST_MAPPING b/tools/protologtool/TEST_MAPPING
new file mode 100644
index 0000000..52b12dc
--- /dev/null
+++ b/tools/protologtool/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "presubmit": [
+    {
+      "name": "protologtool-tests"
+    }
+  ]
+}
diff --git a/tools/protologtool/manifest.txt b/tools/protologtool/manifest.txt
new file mode 100644
index 0000000..f5e53c4
--- /dev/null
+++ b/tools/protologtool/manifest.txt
@@ -0,0 +1 @@
+Main-class: com.android.protologtool.ProtoLogTool
diff --git a/tools/protologtool/src/com/android/protologtool/CodeUtils.kt b/tools/protologtool/src/com/android/protologtool/CodeUtils.kt
new file mode 100644
index 0000000..facca62
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/CodeUtils.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.ImportDeclaration
+import com.github.javaparser.ast.NodeList
+import com.github.javaparser.ast.expr.BinaryExpr
+import com.github.javaparser.ast.expr.Expression
+import com.github.javaparser.ast.expr.MethodCallExpr
+import com.github.javaparser.ast.expr.SimpleName
+import com.github.javaparser.ast.expr.StringLiteralExpr
+import com.github.javaparser.ast.expr.TypeExpr
+import com.github.javaparser.ast.type.PrimitiveType
+import com.github.javaparser.ast.type.Type
+
+object CodeUtils {
+    /**
+     * Returns a stable hash of a string.
+     * We reimplement String::hashCode() for readability reasons.
+     */
+    fun hash(str: String, level: LogLevel): Int {
+        return (level.name + str).map { c -> c.toInt() }.reduce { h, c -> h * 31 + c }
+    }
+
+    fun isWildcardStaticImported(code: CompilationUnit, className: String): Boolean {
+        return code.findAll(ImportDeclaration::class.java)
+                .any { im -> im.isStatic && im.isAsterisk && im.name.toString() == className }
+    }
+
+    fun isClassImportedOrSamePackage(code: CompilationUnit, className: String): Boolean {
+        val packageName = className.substringBeforeLast('.')
+        return code.packageDeclaration.isPresent &&
+                code.packageDeclaration.get().nameAsString == packageName ||
+                code.findAll(ImportDeclaration::class.java)
+                        .any { im ->
+                            !im.isStatic &&
+                                    ((!im.isAsterisk && im.name.toString() == className) ||
+                                            (im.isAsterisk && im.name.toString() == packageName))
+                        }
+    }
+
+    fun staticallyImportedMethods(code: CompilationUnit, className: String): Set<String> {
+        return code.findAll(ImportDeclaration::class.java)
+                .filter { im ->
+                    im.isStatic &&
+                            im.name.toString().substringBeforeLast('.') == className
+                }
+                .map { im -> im.name.toString().substringAfterLast('.') }.toSet()
+    }
+
+    fun concatMultilineString(expr: Expression): String {
+        return when (expr) {
+            is StringLiteralExpr -> expr.asString()
+            is BinaryExpr -> when {
+                expr.operator == BinaryExpr.Operator.PLUS ->
+                    concatMultilineString(expr.left) + concatMultilineString(expr.right)
+                else -> throw InvalidProtoLogCallException(
+                        "messageString must be a string literal " +
+                                "or concatenation of string literals.", expr)
+            }
+            else -> throw InvalidProtoLogCallException("messageString must be a string literal " +
+                    "or concatenation of string literals.", expr)
+        }
+    }
+
+    enum class LogDataTypes(
+        val type: Type,
+        val toType: (Expression) -> Expression = { expr -> expr }
+    ) {
+        // When adding new LogDataType make sure to update {@code logDataTypesToBitMask} accordingly
+        STRING(StaticJavaParser.parseClassOrInterfaceType("String"),
+                { expr ->
+                    MethodCallExpr(TypeExpr(StaticJavaParser.parseClassOrInterfaceType("String")),
+                            SimpleName("valueOf"), NodeList(expr))
+                }),
+        LONG(PrimitiveType.longType()),
+        DOUBLE(PrimitiveType.doubleType()),
+        BOOLEAN(PrimitiveType.booleanType());
+    }
+
+    fun parseFormatString(messageString: String): List<LogDataTypes> {
+        val types = mutableListOf<LogDataTypes>()
+        var i = 0
+        while (i < messageString.length) {
+            if (messageString[i] == '%') {
+                if (i + 1 >= messageString.length) {
+                    throw InvalidFormatStringException("Invalid format string in config")
+                }
+                when (messageString[i + 1]) {
+                    'b' -> types.add(CodeUtils.LogDataTypes.BOOLEAN)
+                    'd', 'o', 'x' -> types.add(CodeUtils.LogDataTypes.LONG)
+                    'f', 'e', 'g' -> types.add(CodeUtils.LogDataTypes.DOUBLE)
+                    's' -> types.add(CodeUtils.LogDataTypes.STRING)
+                    '%' -> {
+                    }
+                    else -> throw InvalidFormatStringException("Invalid format string field" +
+                            " %${messageString[i + 1]}")
+                }
+                i += 2
+            } else {
+                i += 1
+            }
+        }
+        return types
+    }
+
+    fun logDataTypesToBitMask(types: List<LogDataTypes>): Int {
+        if (types.size > 16) {
+            throw InvalidFormatStringException("Too many log call parameters " +
+                    "- max 16 parameters supported")
+        }
+        var mask = 0
+        types.forEachIndexed { idx, type ->
+            val x = LogDataTypes.values().indexOf(type)
+            mask = mask or (x shl (idx * 2))
+        }
+        return mask
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/CommandOptions.kt b/tools/protologtool/src/com/android/protologtool/CommandOptions.kt
new file mode 100644
index 0000000..df49e15
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/CommandOptions.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import java.util.regex.Pattern
+
+class CommandOptions(args: Array<String>) {
+    companion object {
+        const val TRANSFORM_CALLS_CMD = "transform-protolog-calls"
+        const val GENERATE_CONFIG_CMD = "generate-viewer-config"
+        const val READ_LOG_CMD = "read-log"
+        private val commands = setOf(TRANSFORM_CALLS_CMD, GENERATE_CONFIG_CMD, READ_LOG_CMD)
+
+        private const val PROTOLOG_CLASS_PARAM = "--protolog-class"
+        private const val PROTOLOGIMPL_CLASS_PARAM = "--protolog-impl-class"
+        private const val PROTOLOGGROUP_CLASS_PARAM = "--loggroups-class"
+        private const val PROTOLOGGROUP_JAR_PARAM = "--loggroups-jar"
+        private const val VIEWER_CONFIG_JSON_PARAM = "--viewer-conf"
+        private const val OUTPUT_SOURCE_JAR_PARAM = "--output-srcjar"
+        private val parameters = setOf(PROTOLOG_CLASS_PARAM, PROTOLOGIMPL_CLASS_PARAM,
+                PROTOLOGGROUP_CLASS_PARAM, PROTOLOGGROUP_JAR_PARAM, VIEWER_CONFIG_JSON_PARAM,
+                OUTPUT_SOURCE_JAR_PARAM)
+
+        val USAGE = """
+            Usage: ${Constants.NAME} <command> [<args>]
+            Available commands:
+
+            $TRANSFORM_CALLS_CMD $PROTOLOG_CLASS_PARAM <class name> $PROTOLOGIMPL_CLASS_PARAM
+                <class name> $PROTOLOGGROUP_CLASS_PARAM <class name> $PROTOLOGGROUP_JAR_PARAM
+                <config.jar> $OUTPUT_SOURCE_JAR_PARAM <output.srcjar> [<input.java>]
+            - processes java files replacing stub calls with logging code.
+
+            $GENERATE_CONFIG_CMD $PROTOLOG_CLASS_PARAM <class name> $PROTOLOGGROUP_CLASS_PARAM
+                <class name> $PROTOLOGGROUP_JAR_PARAM <config.jar> $VIEWER_CONFIG_JSON_PARAM
+                <viewer.json> [<input.java>]
+            - creates viewer config file from given java files.
+
+            $READ_LOG_CMD $VIEWER_CONFIG_JSON_PARAM <viewer.json> <wm_log.pb>
+            - translates a binary log to a readable format.
+        """.trimIndent()
+
+        private fun validateClassName(name: String): String {
+            if (!Pattern.matches("^([a-z]+[A-Za-z0-9]*\\.)+([A-Za-z0-9]+)$", name)) {
+                throw InvalidCommandException("Invalid class name $name")
+            }
+            return name
+        }
+
+        private fun getParam(paramName: String, params: Map<String, String>): String {
+            if (!params.containsKey(paramName)) {
+                throw InvalidCommandException("Param $paramName required")
+            }
+            return params.getValue(paramName)
+        }
+
+        private fun validateNotSpecified(paramName: String, params: Map<String, String>): String {
+            if (params.containsKey(paramName)) {
+                throw InvalidCommandException("Unsupported param $paramName")
+            }
+            return ""
+        }
+
+        private fun validateJarName(name: String): String {
+            if (!name.endsWith(".jar")) {
+                throw InvalidCommandException("Jar file required, got $name instead")
+            }
+            return name
+        }
+
+        private fun validateSrcJarName(name: String): String {
+            if (!name.endsWith(".srcjar")) {
+                throw InvalidCommandException("Source jar file required, got $name instead")
+            }
+            return name
+        }
+
+        private fun validateJSONName(name: String): String {
+            if (!name.endsWith(".json")) {
+                throw InvalidCommandException("Json file required, got $name instead")
+            }
+            return name
+        }
+
+        private fun validateJavaInputList(list: List<String>): List<String> {
+            if (list.isEmpty()) {
+                throw InvalidCommandException("No java source input files")
+            }
+            list.forEach { name ->
+                if (!name.endsWith(".java")) {
+                    throw InvalidCommandException("Not a java source file $name")
+                }
+            }
+            return list
+        }
+
+        private fun validateLogInputList(list: List<String>): String {
+            if (list.isEmpty()) {
+                throw InvalidCommandException("No log input file")
+            }
+            if (list.size > 1) {
+                throw InvalidCommandException("Only one log input file allowed")
+            }
+            return list[0]
+        }
+    }
+
+    val protoLogClassNameArg: String
+    val protoLogGroupsClassNameArg: String
+    val protoLogImplClassNameArg: String
+    val protoLogGroupsJarArg: String
+    val viewerConfigJsonArg: String
+    val outputSourceJarArg: String
+    val logProtofileArg: String
+    val javaSourceArgs: List<String>
+    val command: String
+
+    init {
+        if (args.isEmpty()) {
+            throw InvalidCommandException("No command specified.")
+        }
+        command = args[0]
+        if (command !in commands) {
+            throw InvalidCommandException("Unknown command.")
+        }
+
+        val params: MutableMap<String, String> = mutableMapOf()
+        val inputFiles: MutableList<String> = mutableListOf()
+
+        var idx = 1
+        while (idx < args.size) {
+            if (args[idx].startsWith("--")) {
+                if (idx + 1 >= args.size) {
+                    throw InvalidCommandException("No value for ${args[idx]}")
+                }
+                if (args[idx] !in parameters) {
+                    throw InvalidCommandException("Unknown parameter ${args[idx]}")
+                }
+                if (args[idx + 1].startsWith("--")) {
+                    throw InvalidCommandException("No value for ${args[idx]}")
+                }
+                if (params.containsKey(args[idx])) {
+                    throw InvalidCommandException("Duplicated parameter ${args[idx]}")
+                }
+                params[args[idx]] = args[idx + 1]
+                idx += 2
+            } else {
+                inputFiles.add(args[idx])
+                idx += 1
+            }
+        }
+
+        when (command) {
+            TRANSFORM_CALLS_CMD -> {
+                protoLogClassNameArg = validateClassName(getParam(PROTOLOG_CLASS_PARAM, params))
+                protoLogGroupsClassNameArg = validateClassName(getParam(PROTOLOGGROUP_CLASS_PARAM,
+                        params))
+                protoLogImplClassNameArg = validateClassName(getParam(PROTOLOGIMPL_CLASS_PARAM,
+                        params))
+                protoLogGroupsJarArg = validateJarName(getParam(PROTOLOGGROUP_JAR_PARAM, params))
+                viewerConfigJsonArg = validateNotSpecified(VIEWER_CONFIG_JSON_PARAM, params)
+                outputSourceJarArg = validateSrcJarName(getParam(OUTPUT_SOURCE_JAR_PARAM, params))
+                javaSourceArgs = validateJavaInputList(inputFiles)
+                logProtofileArg = ""
+            }
+            GENERATE_CONFIG_CMD -> {
+                protoLogClassNameArg = validateClassName(getParam(PROTOLOG_CLASS_PARAM, params))
+                protoLogGroupsClassNameArg = validateClassName(getParam(PROTOLOGGROUP_CLASS_PARAM,
+                        params))
+                protoLogImplClassNameArg = validateNotSpecified(PROTOLOGIMPL_CLASS_PARAM, params)
+                protoLogGroupsJarArg = validateJarName(getParam(PROTOLOGGROUP_JAR_PARAM, params))
+                viewerConfigJsonArg = validateJSONName(getParam(VIEWER_CONFIG_JSON_PARAM, params))
+                outputSourceJarArg = validateNotSpecified(OUTPUT_SOURCE_JAR_PARAM, params)
+                javaSourceArgs = validateJavaInputList(inputFiles)
+                logProtofileArg = ""
+            }
+            READ_LOG_CMD -> {
+                protoLogClassNameArg = validateNotSpecified(PROTOLOG_CLASS_PARAM, params)
+                protoLogGroupsClassNameArg = validateNotSpecified(PROTOLOGGROUP_CLASS_PARAM, params)
+                protoLogImplClassNameArg = validateNotSpecified(PROTOLOGIMPL_CLASS_PARAM, params)
+                protoLogGroupsJarArg = validateNotSpecified(PROTOLOGGROUP_JAR_PARAM, params)
+                viewerConfigJsonArg = validateJSONName(getParam(VIEWER_CONFIG_JSON_PARAM, params))
+                outputSourceJarArg = validateNotSpecified(OUTPUT_SOURCE_JAR_PARAM, params)
+                javaSourceArgs = listOf()
+                logProtofileArg = validateLogInputList(inputFiles)
+            }
+            else -> {
+                throw InvalidCommandException("Unknown command.")
+            }
+        }
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/Constants.kt b/tools/protologtool/src/com/android/protologtool/Constants.kt
new file mode 100644
index 0000000..2ccfc4d
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/Constants.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+object Constants {
+        const val NAME = "protologtool"
+        const val VERSION = "1.0.0"
+        const val IS_ENABLED_METHOD = "isEnabled"
+        const val IS_LOG_TO_LOGCAT_METHOD = "isLogToLogcat"
+        const val IS_LOG_TO_ANY_METHOD = "isLogToAny"
+        const val GET_TAG_METHOD = "getTag"
+        const val ENUM_VALUES_METHOD = "values"
+}
diff --git a/tools/protologtool/src/com/android/protologtool/LogGroup.kt b/tools/protologtool/src/com/android/protologtool/LogGroup.kt
new file mode 100644
index 0000000..42a37a2
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/LogGroup.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+data class LogGroup(
+    val name: String,
+    val enabled: Boolean,
+    val textEnabled: Boolean,
+    val tag: String
+)
diff --git a/tools/protologtool/src/com/android/protologtool/LogLevel.kt b/tools/protologtool/src/com/android/protologtool/LogLevel.kt
new file mode 100644
index 0000000..dc29557
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/LogLevel.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.ast.Node
+
+enum class LogLevel {
+    DEBUG, VERBOSE, INFO, WARN, ERROR, WTF;
+
+    companion object {
+        fun getLevelForMethodName(name: String, node: Node): LogLevel {
+            return when (name) {
+                "d" -> DEBUG
+                "v" -> VERBOSE
+                "i" -> INFO
+                "w" -> WARN
+                "e" -> ERROR
+                "wtf" -> WTF
+                else -> throw InvalidProtoLogCallException("Unknown log level $name", node)
+            }
+        }
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/LogParser.kt b/tools/protologtool/src/com/android/protologtool/LogParser.kt
new file mode 100644
index 0000000..4d0eb0e
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/LogParser.kt
@@ -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 com.android.protologtool
+
+import com.android.json.stream.JsonReader
+import com.android.server.wm.ProtoLogMessage
+import com.android.server.wm.WindowManagerLogFileProto
+import java.io.BufferedReader
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.PrintStream
+import java.lang.Exception
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Implements a simple parser/viewer for binary ProtoLog logs.
+ * A binary log is translated into Android "LogCat"-like text log.
+ */
+class LogParser(private val configParser: ViewerConfigParser) {
+    companion object {
+        private val dateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
+        private val magicNumber =
+                WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_H.number.toLong() shl 32 or
+                        WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_L.number.toLong()
+    }
+
+    private fun printTime(time: Long, offset: Long, ps: PrintStream) {
+        ps.print(dateFormat.format(Date(time / 1000000 + offset)) + " ")
+    }
+
+    private fun printFormatted(
+        protoLogMessage: ProtoLogMessage,
+        configEntry: ViewerConfigParser.ConfigEntry,
+        ps: PrintStream
+    ) {
+        val strParmIt = protoLogMessage.strParamsList.iterator()
+        val longParamsIt = protoLogMessage.sint64ParamsList.iterator()
+        val doubleParamsIt = protoLogMessage.doubleParamsList.iterator()
+        val boolParamsIt = protoLogMessage.booleanParamsList.iterator()
+        val args = mutableListOf<Any>()
+        val format = configEntry.messageString
+        val argTypes = CodeUtils.parseFormatString(format)
+        try {
+            argTypes.forEach {
+                when (it) {
+                    CodeUtils.LogDataTypes.BOOLEAN -> args.add(boolParamsIt.next())
+                    CodeUtils.LogDataTypes.LONG -> args.add(longParamsIt.next())
+                    CodeUtils.LogDataTypes.DOUBLE -> args.add(doubleParamsIt.next())
+                    CodeUtils.LogDataTypes.STRING -> args.add(strParmIt.next())
+                }
+            }
+        } catch (ex: NoSuchElementException) {
+            throw InvalidFormatStringException("Invalid format string in config", ex)
+        }
+        if (strParmIt.hasNext() || longParamsIt.hasNext() ||
+                doubleParamsIt.hasNext() || boolParamsIt.hasNext()) {
+            throw RuntimeException("Invalid format string in config - no enough matchers")
+        }
+        val formatted = format.format(*(args.toTypedArray()))
+        ps.print("${configEntry.level} ${configEntry.tag}: $formatted\n")
+    }
+
+    private fun printUnformatted(protoLogMessage: ProtoLogMessage, ps: PrintStream, tag: String) {
+        ps.println("$tag: ${protoLogMessage.messageHash} - ${protoLogMessage.strParamsList}" +
+                " ${protoLogMessage.sint64ParamsList} ${protoLogMessage.doubleParamsList}" +
+                " ${protoLogMessage.booleanParamsList}")
+    }
+
+    fun parse(protoLogInput: InputStream, jsonConfigInput: InputStream, ps: PrintStream) {
+        val jsonReader = JsonReader(BufferedReader(InputStreamReader(jsonConfigInput)))
+        val config = configParser.parseConfig(jsonReader)
+        val protoLog = WindowManagerLogFileProto.parseFrom(protoLogInput)
+
+        if (protoLog.magicNumber != magicNumber) {
+            throw InvalidInputException("ProtoLog file magic number is invalid.")
+        }
+        if (protoLog.version != Constants.VERSION) {
+            throw InvalidInputException("ProtoLog file version not supported by this tool," +
+                    " log version ${protoLog.version}, viewer version ${Constants.VERSION}")
+        }
+
+        protoLog.logList.forEach { log ->
+            printTime(log.elapsedRealtimeNanos, protoLog.realTimeToElapsedTimeOffsetMillis, ps)
+            if (log.messageHash !in config) {
+                printUnformatted(log, ps, "UNKNOWN")
+            } else {
+                val conf = config.getValue(log.messageHash)
+                try {
+                    printFormatted(log, conf, ps)
+                } catch (ex: Exception) {
+                    printUnformatted(log, ps, "INVALID")
+                }
+            }
+        }
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ProtoLogCallProcessor.kt b/tools/protologtool/src/com/android/protologtool/ProtoLogCallProcessor.kt
new file mode 100644
index 0000000..29d8ae5
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ProtoLogCallProcessor.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.expr.Expression
+import com.github.javaparser.ast.expr.FieldAccessExpr
+import com.github.javaparser.ast.expr.MethodCallExpr
+import com.github.javaparser.ast.expr.NameExpr
+
+/**
+ * Helper class for visiting all ProtoLog calls.
+ * For every valid call in the given {@code CompilationUnit} a {@code ProtoLogCallVisitor} callback
+ * is executed.
+ */
+open class ProtoLogCallProcessor(
+    private val protoLogClassName: String,
+    private val protoLogGroupClassName: String,
+    private val groupMap: Map<String, LogGroup>
+) {
+    private val protoLogSimpleClassName = protoLogClassName.substringAfterLast('.')
+    private val protoLogGroupSimpleClassName = protoLogGroupClassName.substringAfterLast('.')
+
+    private fun getLogGroupName(
+        expr: Expression,
+        isClassImported: Boolean,
+        staticImports: Set<String>
+    ): String {
+        return when (expr) {
+            is NameExpr -> when {
+                expr.nameAsString in staticImports -> expr.nameAsString
+                else ->
+                    throw InvalidProtoLogCallException("Unknown/not imported ProtoLogGroup", expr)
+            }
+            is FieldAccessExpr -> when {
+                expr.scope.toString() == protoLogGroupClassName
+                        || isClassImported &&
+                        expr.scope.toString() == protoLogGroupSimpleClassName -> expr.nameAsString
+                else ->
+                    throw InvalidProtoLogCallException("Unknown/not imported ProtoLogGroup", expr)
+            }
+            else -> throw InvalidProtoLogCallException("Invalid group argument " +
+                    "- must be ProtoLogGroup enum member reference", expr)
+        }
+    }
+
+    private fun isProtoCall(
+        call: MethodCallExpr,
+        isLogClassImported: Boolean,
+        staticLogImports: Collection<String>
+    ): Boolean {
+        return call.scope.isPresent && call.scope.get().toString() == protoLogClassName ||
+                isLogClassImported && call.scope.isPresent &&
+                call.scope.get().toString() == protoLogSimpleClassName ||
+                !call.scope.isPresent && staticLogImports.contains(call.name.toString())
+    }
+
+    open fun process(code: CompilationUnit, callVisitor: ProtoLogCallVisitor?): CompilationUnit {
+        if (CodeUtils.isWildcardStaticImported(code, protoLogClassName) ||
+                CodeUtils.isWildcardStaticImported(code, protoLogGroupClassName)) {
+            throw IllegalImportException("Wildcard static imports of $protoLogClassName " +
+                    "and $protoLogGroupClassName methods are not supported.")
+        }
+
+        val isLogClassImported = CodeUtils.isClassImportedOrSamePackage(code, protoLogClassName)
+        val staticLogImports = CodeUtils.staticallyImportedMethods(code, protoLogClassName)
+        val isGroupClassImported = CodeUtils.isClassImportedOrSamePackage(code,
+                protoLogGroupClassName)
+        val staticGroupImports = CodeUtils.staticallyImportedMethods(code, protoLogGroupClassName)
+
+        code.findAll(MethodCallExpr::class.java)
+                .filter { call ->
+                    isProtoCall(call, isLogClassImported, staticLogImports)
+                }.forEach { call ->
+                    if (call.arguments.size < 2) {
+                        throw InvalidProtoLogCallException("Method signature does not match " +
+                                "any ProtoLog method.", call)
+                    }
+
+                    val messageString = CodeUtils.concatMultilineString(call.getArgument(1))
+                    val groupNameArg = call.getArgument(0)
+                    val groupName =
+                            getLogGroupName(groupNameArg, isGroupClassImported, staticGroupImports)
+                    if (groupName !in groupMap) {
+                        throw InvalidProtoLogCallException("Unknown group argument " +
+                                "- not a ProtoLogGroup enum member", call)
+                    }
+
+                    callVisitor?.processCall(call, messageString, LogLevel.getLevelForMethodName(
+                            call.name.toString(), call), groupMap.getValue(groupName))
+                }
+        return code
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ProtoLogCallVisitor.kt b/tools/protologtool/src/com/android/protologtool/ProtoLogCallVisitor.kt
new file mode 100644
index 0000000..42a75f8
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ProtoLogCallVisitor.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.ast.expr.MethodCallExpr
+
+interface ProtoLogCallVisitor {
+    fun processCall(call: MethodCallExpr, messageString: String, level: LogLevel, group: LogGroup)
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ProtoLogGroupReader.kt b/tools/protologtool/src/com/android/protologtool/ProtoLogGroupReader.kt
new file mode 100644
index 0000000..664c8a6
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ProtoLogGroupReader.kt
@@ -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 com.android.protologtool
+
+import com.android.protologtool.Constants.ENUM_VALUES_METHOD
+import com.android.protologtool.Constants.GET_TAG_METHOD
+import com.android.protologtool.Constants.IS_ENABLED_METHOD
+import com.android.protologtool.Constants.IS_LOG_TO_LOGCAT_METHOD
+import java.io.File
+import java.lang.RuntimeException
+import java.net.URLClassLoader
+
+class ProtoLogGroupReader {
+    private fun getClassloaderForJar(jarPath: String): ClassLoader {
+        val jarFile = File(jarPath)
+        val url = jarFile.toURI().toURL()
+        return URLClassLoader(arrayOf(url), ProtoLogGroupReader::class.java.classLoader)
+    }
+
+    private fun getEnumValues(clazz: Class<*>): List<Enum<*>> {
+        val valuesMethod = clazz.getMethod(ENUM_VALUES_METHOD)
+        @Suppress("UNCHECKED_CAST")
+        return (valuesMethod.invoke(null) as Array<Enum<*>>).toList()
+    }
+
+    private fun getLogGroupFromEnumValue(group: Any, clazz: Class<*>): LogGroup {
+        val enabled = clazz.getMethod(IS_ENABLED_METHOD).invoke(group) as Boolean
+        val textEnabled = clazz.getMethod(IS_LOG_TO_LOGCAT_METHOD).invoke(group) as Boolean
+        val tag = clazz.getMethod(GET_TAG_METHOD).invoke(group) as String
+        val name = (group as Enum<*>).name
+        return LogGroup(name, enabled, textEnabled, tag)
+    }
+
+    fun loadFromJar(jarPath: String, className: String): Map<String, LogGroup> {
+        try {
+            val classLoader = getClassloaderForJar(jarPath)
+            val clazz = classLoader.loadClass(className)
+            val values = getEnumValues(clazz)
+            return values.map { group ->
+                group.name to getLogGroupFromEnumValue(group, clazz)
+            }.toMap()
+        } catch (ex: ReflectiveOperationException) {
+            throw RuntimeException("Unable to load ProtoLogGroup enum class", ex)
+        }
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ProtoLogTool.kt b/tools/protologtool/src/com/android/protologtool/ProtoLogTool.kt
new file mode 100644
index 0000000..485a047
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ProtoLogTool.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.protologtool.CommandOptions.Companion.USAGE
+import com.github.javaparser.StaticJavaParser
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.util.jar.JarOutputStream
+import java.util.zip.ZipEntry
+import kotlin.system.exitProcess
+
+object ProtoLogTool {
+    private fun showHelpAndExit() {
+        println(USAGE)
+        exitProcess(-1)
+    }
+
+    private fun processClasses(command: CommandOptions) {
+        val groups = ProtoLogGroupReader()
+                .loadFromJar(command.protoLogGroupsJarArg, command.protoLogGroupsClassNameArg)
+        val out = FileOutputStream(command.outputSourceJarArg)
+        val outJar = JarOutputStream(out)
+        val processor = ProtoLogCallProcessor(command.protoLogClassNameArg,
+                command.protoLogGroupsClassNameArg, groups)
+        val transformer = SourceTransformer(command.protoLogImplClassNameArg, processor)
+
+        command.javaSourceArgs.forEach { path ->
+            val file = File(path)
+            val code = StaticJavaParser.parse(file)
+            val outSrc = transformer.processClass(code)
+            val pack = if (code.packageDeclaration.isPresent) code.packageDeclaration
+                    .get().nameAsString else ""
+            val newPath = pack.replace('.', '/') + '/' + file.name
+            outJar.putNextEntry(ZipEntry(newPath))
+            outJar.write(outSrc.toByteArray())
+            outJar.closeEntry()
+        }
+
+        outJar.close()
+        out.close()
+    }
+
+    private fun viewerConf(command: CommandOptions) {
+        val groups = ProtoLogGroupReader()
+                .loadFromJar(command.protoLogGroupsJarArg, command.protoLogGroupsClassNameArg)
+        val processor = ProtoLogCallProcessor(command.protoLogClassNameArg,
+                command.protoLogGroupsClassNameArg, groups)
+        val builder = ViewerConfigBuilder(processor)
+        command.javaSourceArgs.forEach { path ->
+            val file = File(path)
+            builder.processClass(StaticJavaParser.parse(file))
+        }
+        val out = FileOutputStream(command.viewerConfigJsonArg)
+        out.write(builder.build().toByteArray())
+        out.close()
+    }
+
+    fun read(command: CommandOptions) {
+        LogParser(ViewerConfigParser())
+                .parse(FileInputStream(command.logProtofileArg),
+                        FileInputStream(command.viewerConfigJsonArg), System.out)
+    }
+
+    @JvmStatic
+    fun main(args: Array<String>) {
+        try {
+            val command = CommandOptions(args)
+            when (command.command) {
+                CommandOptions.TRANSFORM_CALLS_CMD -> processClasses(command)
+                CommandOptions.GENERATE_CONFIG_CMD -> viewerConf(command)
+                CommandOptions.READ_LOG_CMD -> read(command)
+            }
+        } catch (ex: InvalidCommandException) {
+            println(ex.message)
+            showHelpAndExit()
+        }
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/SourceTransformer.kt b/tools/protologtool/src/com/android/protologtool/SourceTransformer.kt
new file mode 100644
index 0000000..319a8170
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/SourceTransformer.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.protologtool.Constants.IS_LOG_TO_ANY_METHOD
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.NodeList
+import com.github.javaparser.ast.body.VariableDeclarator
+import com.github.javaparser.ast.expr.BooleanLiteralExpr
+import com.github.javaparser.ast.expr.CastExpr
+import com.github.javaparser.ast.expr.FieldAccessExpr
+import com.github.javaparser.ast.expr.IntegerLiteralExpr
+import com.github.javaparser.ast.expr.MethodCallExpr
+import com.github.javaparser.ast.expr.NameExpr
+import com.github.javaparser.ast.expr.NullLiteralExpr
+import com.github.javaparser.ast.expr.SimpleName
+import com.github.javaparser.ast.expr.VariableDeclarationExpr
+import com.github.javaparser.ast.stmt.BlockStmt
+import com.github.javaparser.ast.stmt.ExpressionStmt
+import com.github.javaparser.ast.stmt.IfStmt
+import com.github.javaparser.ast.type.ArrayType
+import com.github.javaparser.printer.PrettyPrinter
+import com.github.javaparser.printer.PrettyPrinterConfiguration
+import com.github.javaparser.printer.lexicalpreservation.LexicalPreservingPrinter
+
+class SourceTransformer(
+    protoLogImplClassName: String,
+    private val protoLogCallProcessor: ProtoLogCallProcessor
+) : ProtoLogCallVisitor {
+    override fun processCall(
+        call: MethodCallExpr,
+        messageString: String,
+        level: LogLevel,
+        group: LogGroup
+    ) {
+        // Input format: ProtoLog.e(GROUP, "msg %d", arg)
+        if (!call.parentNode.isPresent) {
+            // Should never happen
+            throw RuntimeException("Unable to process log call $call " +
+                    "- no parent node in AST")
+        }
+        if (call.parentNode.get() !is ExpressionStmt) {
+            // Should never happen
+            throw RuntimeException("Unable to process log call $call " +
+                    "- parent node in AST is not an ExpressionStmt")
+        }
+        val parentStmt = call.parentNode.get() as ExpressionStmt
+        if (!parentStmt.parentNode.isPresent) {
+            // Should never happen
+            throw RuntimeException("Unable to process log call $call " +
+                    "- no grandparent node in AST")
+        }
+        val ifStmt: IfStmt
+        if (group.enabled) {
+            val hash = CodeUtils.hash(messageString, level)
+            val newCall = call.clone()
+            if (!group.textEnabled) {
+                // Remove message string if text logging is not enabled by default.
+                // Out: ProtoLog.e(GROUP, null, arg)
+                newCall.arguments[1].replace(NameExpr("null"))
+            }
+            // Insert message string hash as a second argument.
+            // Out: ProtoLog.e(GROUP, 1234, null, arg)
+            newCall.arguments.add(1, IntegerLiteralExpr(hash))
+            val argTypes = CodeUtils.parseFormatString(messageString)
+            val typeMask = CodeUtils.logDataTypesToBitMask(argTypes)
+            // Insert bitmap representing which Number parameters are to be considered as
+            // floating point numbers.
+            // Out: ProtoLog.e(GROUP, 1234, 0, null, arg)
+            newCall.arguments.add(2, IntegerLiteralExpr(typeMask))
+            // Replace call to a stub method with an actual implementation.
+            // Out: com.android.server.wm.ProtoLogImpl.e(GROUP, 1234, null, arg)
+            newCall.setScope(protoLogImplClassNode)
+            // Create a call to GROUP.isLogAny()
+            // Out: GROUP.isLogAny()
+            val isLogAnyExpr = MethodCallExpr(newCall.arguments[0].clone(),
+                    SimpleName(IS_LOG_TO_ANY_METHOD))
+            if (argTypes.size != call.arguments.size - 2) {
+                throw InvalidProtoLogCallException(
+                        "Number of arguments does not mach format string", call)
+            }
+            val blockStmt = BlockStmt()
+            if (argTypes.isNotEmpty()) {
+                // Assign every argument to a variable to check its type in compile time
+                // (this is assignment is optimized-out by dex tool, there is no runtime impact)/
+                // Out: long protoLogParam0 = arg
+                argTypes.forEachIndexed { idx, type ->
+                    val varName = "protoLogParam$idx"
+                    val declaration = VariableDeclarator(type.type, varName,
+                            type.toType(newCall.arguments[idx + 4].clone()))
+                    blockStmt.addStatement(ExpressionStmt(VariableDeclarationExpr(declaration)))
+                    newCall.setArgument(idx + 4, NameExpr(SimpleName(varName)))
+                }
+            } else {
+                // Assign (Object[])null as the vararg parameter to prevent allocating an empty
+                // object array.
+                val nullArray = CastExpr(ArrayType(objectType), NullLiteralExpr())
+                newCall.addArgument(nullArray)
+            }
+            blockStmt.addStatement(ExpressionStmt(newCall))
+            // Create an IF-statement with the previously created condition.
+            // Out: if (GROUP.isLogAny()) {
+            //          long protoLogParam0 = arg;
+            //          com.android.server.wm.ProtoLogImpl.e(GROUP, 1234, 0, null, protoLogParam0);
+            //      }
+            ifStmt = IfStmt(isLogAnyExpr, blockStmt, null)
+        } else {
+            // Surround with if (false).
+            val newCall = parentStmt.clone()
+            ifStmt = IfStmt(BooleanLiteralExpr(false), BlockStmt(NodeList(newCall)), null)
+            newCall.setBlockComment(" ${group.name} is disabled ")
+        }
+        // Inline the new statement.
+        val printedIfStmt = inlinePrinter.print(ifStmt)
+        // Append blank lines to preserve line numbering in file (to allow debugging)
+        val newLines = LexicalPreservingPrinter.print(parentStmt).count { c -> c == '\n' }
+        val newStmt = printedIfStmt.substringBeforeLast('}') + ("\n".repeat(newLines)) + '}'
+        val inlinedIfStmt = StaticJavaParser.parseStatement(newStmt)
+        LexicalPreservingPrinter.setup(inlinedIfStmt)
+        // Replace the original call.
+        if (!parentStmt.replace(inlinedIfStmt)) {
+            // Should never happen
+            throw RuntimeException("Unable to process log call $call " +
+                    "- unable to replace the call.")
+        }
+    }
+
+    private val inlinePrinter: PrettyPrinter
+    private val objectType = StaticJavaParser.parseClassOrInterfaceType("Object")
+
+    init {
+        val config = PrettyPrinterConfiguration()
+        config.endOfLineCharacter = " "
+        config.indentSize = 0
+        config.tabWidth = 1
+        inlinePrinter = PrettyPrinter(config)
+    }
+
+    private val protoLogImplClassNode =
+            StaticJavaParser.parseExpression<FieldAccessExpr>(protoLogImplClassName)
+
+    fun processClass(compilationUnit: CompilationUnit): String {
+        LexicalPreservingPrinter.setup(compilationUnit)
+        protoLogCallProcessor.process(compilationUnit, this)
+        return LexicalPreservingPrinter.print(compilationUnit)
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ViewerConfigBuilder.kt b/tools/protologtool/src/com/android/protologtool/ViewerConfigBuilder.kt
new file mode 100644
index 0000000..8ce9a49
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ViewerConfigBuilder.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.json.stream.JsonWriter
+import com.github.javaparser.ast.CompilationUnit
+import com.android.protologtool.Constants.VERSION
+import com.github.javaparser.ast.expr.MethodCallExpr
+import java.io.StringWriter
+
+class ViewerConfigBuilder(
+    private val protoLogCallVisitor: ProtoLogCallProcessor
+) : ProtoLogCallVisitor {
+    override fun processCall(
+        call: MethodCallExpr,
+        messageString: String,
+        level: LogLevel,
+        group: LogGroup
+    ) {
+        if (group.enabled) {
+            val key = CodeUtils.hash(messageString, level)
+            if (statements.containsKey(key)) {
+                if (statements[key] != Triple(messageString, level, group)) {
+                    throw HashCollisionException(
+                            "Please modify the log message \"$messageString\" " +
+                                    "or \"${statements[key]}\" - their hashes are equal.")
+                }
+            } else {
+                groups.add(group)
+                statements[key] = Triple(messageString, level, group)
+            }
+        }
+    }
+
+    private val statements: MutableMap<Int, Triple<String, LogLevel, LogGroup>> = mutableMapOf()
+    private val groups: MutableSet<LogGroup> = mutableSetOf()
+
+    fun processClass(unit: CompilationUnit) {
+        protoLogCallVisitor.process(unit, this)
+    }
+
+    fun build(): String {
+        val stringWriter = StringWriter()
+        val writer = JsonWriter(stringWriter)
+        writer.setIndent("  ")
+        writer.beginObject()
+        writer.name("version")
+        writer.value(VERSION)
+        writer.name("messages")
+        writer.beginObject()
+        statements.toSortedMap().forEach { (key, value) ->
+            writer.name(key.toString())
+            writer.beginObject()
+            writer.name("message")
+            writer.value(value.first)
+            writer.name("level")
+            writer.value(value.second.name)
+            writer.name("group")
+            writer.value(value.third.name)
+            writer.endObject()
+        }
+        writer.endObject()
+        writer.name("groups")
+        writer.beginObject()
+        groups.toSortedSet(Comparator { o1, o2 -> o1.name.compareTo(o2.name) }).forEach { group ->
+            writer.name(group.name)
+            writer.beginObject()
+            writer.name("tag")
+            writer.value(group.tag)
+            writer.endObject()
+        }
+        writer.endObject()
+        writer.endObject()
+        stringWriter.buffer.append('\n')
+        return stringWriter.toString()
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/ViewerConfigParser.kt b/tools/protologtool/src/com/android/protologtool/ViewerConfigParser.kt
new file mode 100644
index 0000000..69cf92d
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/ViewerConfigParser.kt
@@ -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 com.android.protologtool
+
+import com.android.json.stream.JsonReader
+
+open class ViewerConfigParser {
+    data class MessageEntry(
+        val messageString: String,
+        val level: String,
+        val groupName: String
+    )
+
+    fun parseMessage(jsonReader: JsonReader): MessageEntry {
+        jsonReader.beginObject()
+        var message: String? = null
+        var level: String? = null
+        var groupName: String? = null
+        while (jsonReader.hasNext()) {
+            val key = jsonReader.nextName()
+            when (key) {
+                "message" -> message = jsonReader.nextString()
+                "level" -> level = jsonReader.nextString()
+                "group" -> groupName = jsonReader.nextString()
+                else -> jsonReader.skipValue()
+            }
+        }
+        jsonReader.endObject()
+        if (message.isNullOrBlank() || level.isNullOrBlank() || groupName.isNullOrBlank()) {
+            throw InvalidViewerConfigException("Invalid message entry in viewer config")
+        }
+        return MessageEntry(message, level, groupName)
+    }
+
+    data class GroupEntry(val tag: String)
+
+    fun parseGroup(jsonReader: JsonReader): GroupEntry {
+        jsonReader.beginObject()
+        var tag: String? = null
+        while (jsonReader.hasNext()) {
+            val key = jsonReader.nextName()
+            when (key) {
+                "tag" -> tag = jsonReader.nextString()
+                else -> jsonReader.skipValue()
+            }
+        }
+        jsonReader.endObject()
+        if (tag.isNullOrBlank()) {
+            throw InvalidViewerConfigException("Invalid group entry in viewer config")
+        }
+        return GroupEntry(tag)
+    }
+
+    fun parseMessages(jsonReader: JsonReader): Map<Int, MessageEntry> {
+        val config: MutableMap<Int, MessageEntry> = mutableMapOf()
+        jsonReader.beginObject()
+        while (jsonReader.hasNext()) {
+            val key = jsonReader.nextName()
+            val hash = key.toIntOrNull()
+                    ?: throw InvalidViewerConfigException("Invalid key in messages viewer config")
+            config[hash] = parseMessage(jsonReader)
+        }
+        jsonReader.endObject()
+        return config
+    }
+
+    fun parseGroups(jsonReader: JsonReader): Map<String, GroupEntry> {
+        val config: MutableMap<String, GroupEntry> = mutableMapOf()
+        jsonReader.beginObject()
+        while (jsonReader.hasNext()) {
+            val key = jsonReader.nextName()
+            config[key] = parseGroup(jsonReader)
+        }
+        jsonReader.endObject()
+        return config
+    }
+
+    data class ConfigEntry(val messageString: String, val level: String, val tag: String)
+
+    open fun parseConfig(jsonReader: JsonReader): Map<Int, ConfigEntry> {
+        var messages: Map<Int, MessageEntry>? = null
+        var groups: Map<String, GroupEntry>? = null
+        var version: String? = null
+
+        jsonReader.beginObject()
+        while (jsonReader.hasNext()) {
+            val key = jsonReader.nextName()
+            when (key) {
+                "messages" -> messages = parseMessages(jsonReader)
+                "groups" -> groups = parseGroups(jsonReader)
+                "version" -> version = jsonReader.nextString()
+
+                else -> jsonReader.skipValue()
+            }
+        }
+        jsonReader.endObject()
+        if (messages == null || groups == null || version == null) {
+            throw InvalidViewerConfigException("Invalid config - definitions missing")
+        }
+        if (version != Constants.VERSION) {
+            throw InvalidViewerConfigException("Viewer config version not supported by this tool," +
+                    " config version $version, viewer version ${Constants.VERSION}")
+        }
+        return messages.map { msg ->
+            msg.key to ConfigEntry(
+                    msg.value.messageString, msg.value.level, groups[msg.value.groupName]?.tag
+                    ?: throw InvalidViewerConfigException(
+                            "Group definition missing for ${msg.value.groupName}"))
+        }.toMap()
+    }
+}
diff --git a/tools/protologtool/src/com/android/protologtool/exceptions.kt b/tools/protologtool/src/com/android/protologtool/exceptions.kt
new file mode 100644
index 0000000..2199785
--- /dev/null
+++ b/tools/protologtool/src/com/android/protologtool/exceptions.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.ast.Node
+import java.lang.Exception
+import java.lang.RuntimeException
+
+class HashCollisionException(message: String) : RuntimeException(message)
+
+class IllegalImportException(message: String) : Exception(message)
+
+class InvalidProtoLogCallException(message: String, node: Node)
+    : RuntimeException("$message\nAt: $node")
+
+class InvalidViewerConfigException : Exception {
+    constructor(message: String) : super(message)
+
+    constructor(message: String, ex: Exception) : super(message, ex)
+}
+
+class InvalidFormatStringException : Exception {
+    constructor(message: String) : super(message)
+
+    constructor(message: String, ex: Exception) : super(message, ex)
+}
+
+class InvalidInputException(message: String) : Exception(message)
+
+class InvalidCommandException(message: String) : Exception(message)
diff --git a/tools/protologtool/tests/com/android/protologtool/CodeUtilsTest.kt b/tools/protologtool/tests/com/android/protologtool/CodeUtilsTest.kt
new file mode 100644
index 0000000..82daa73
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/CodeUtilsTest.kt
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.ast.expr.BinaryExpr
+import com.github.javaparser.ast.expr.StringLiteralExpr
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CodeUtilsTest {
+    @Test
+    fun hash() {
+        assertEquals(-1704685243, CodeUtils.hash("test", LogLevel.DEBUG))
+    }
+
+    @Test
+    fun hash_changeLevel() {
+        assertEquals(-1176900998, CodeUtils.hash("test", LogLevel.ERROR))
+    }
+
+    @Test
+    fun hash_changeMessage() {
+        assertEquals(-1305634931, CodeUtils.hash("test2", LogLevel.DEBUG))
+    }
+
+    @Test
+    fun isWildcardStaticImported_true() {
+        val code = """package org.example.test;
+            import static org.example.Test.*;
+        """
+        assertTrue(CodeUtils.isWildcardStaticImported(
+                StaticJavaParser.parse(code), "org.example.Test"))
+    }
+
+    @Test
+    fun isWildcardStaticImported_notStatic() {
+        val code = """package org.example.test;
+            import org.example.Test.*;
+        """
+        assertFalse(CodeUtils.isWildcardStaticImported(
+                StaticJavaParser.parse(code), "org.example.Test"))
+    }
+
+    @Test
+    fun isWildcardStaticImported_differentClass() {
+        val code = """package org.example.test;
+            import static org.example.Test2.*;
+        """
+        assertFalse(CodeUtils.isWildcardStaticImported(
+                StaticJavaParser.parse(code), "org.example.Test"))
+    }
+
+    @Test
+    fun isWildcardStaticImported_notWildcard() {
+        val code = """package org.example.test;
+            import org.example.Test.test;
+        """
+        assertFalse(CodeUtils.isWildcardStaticImported(
+                StaticJavaParser.parse(code), "org.example.Test"))
+    }
+
+    @Test
+    fun isClassImportedOrSamePackage_imported() {
+        val code = """package org.example.test;
+            import org.example.Test;
+        """
+        assertTrue(CodeUtils.isClassImportedOrSamePackage(
+                StaticJavaParser.parse(code), "org.example.Test"))
+    }
+
+    @Test
+    fun isClassImportedOrSamePackage_samePackage() {
+        val code = """package org.example.test;
+        """
+        assertTrue(CodeUtils.isClassImportedOrSamePackage(
+                StaticJavaParser.parse(code), "org.example.test.Test"))
+    }
+
+    @Test
+    fun isClassImportedOrSamePackage_false() {
+        val code = """package org.example.test;
+            import org.example.Test;
+        """
+        assertFalse(CodeUtils.isClassImportedOrSamePackage(
+                StaticJavaParser.parse(code), "org.example.Test2"))
+    }
+
+    @Test
+    fun staticallyImportedMethods_ab() {
+        val code = """
+            import static org.example.Test.a;
+            import static org.example.Test.b;
+        """
+        val imported = CodeUtils.staticallyImportedMethods(StaticJavaParser.parse(code),
+                "org.example.Test")
+        assertTrue(imported.containsAll(listOf("a", "b")))
+        assertEquals(2, imported.size)
+    }
+
+    @Test
+    fun staticallyImportedMethods_differentClass() {
+        val code = """
+            import static org.example.Test.a;
+            import static org.example.Test2.b;
+        """
+        val imported = CodeUtils.staticallyImportedMethods(StaticJavaParser.parse(code),
+                "org.example.Test")
+        assertTrue(imported.containsAll(listOf("a")))
+        assertEquals(1, imported.size)
+    }
+
+    @Test
+    fun staticallyImportedMethods_notStatic() {
+        val code = """
+            import static org.example.Test.a;
+            import org.example.Test.b;
+        """
+        val imported = CodeUtils.staticallyImportedMethods(StaticJavaParser.parse(code),
+                "org.example.Test")
+        assertTrue(imported.containsAll(listOf("a")))
+        assertEquals(1, imported.size)
+    }
+
+    @Test
+    fun concatMultilineString_single() {
+        val str = StringLiteralExpr("test")
+        val out = CodeUtils.concatMultilineString(str)
+        assertEquals("test", out)
+    }
+
+    @Test
+    fun concatMultilineString_double() {
+        val str = """
+            "test" + "abc"
+        """
+        val code = StaticJavaParser.parseExpression<BinaryExpr>(str)
+        val out = CodeUtils.concatMultilineString(code)
+        assertEquals("testabc", out)
+    }
+
+    @Test
+    fun concatMultilineString_multiple() {
+        val str = """
+            "test" + "abc" + "1234" + "test"
+        """
+        val code = StaticJavaParser.parseExpression<BinaryExpr>(str)
+        val out = CodeUtils.concatMultilineString(code)
+        assertEquals("testabc1234test", out)
+    }
+
+    @Test
+    fun parseFormatString() {
+        val str = "%b %d %o %x %f %e %g %s %%"
+        val out = CodeUtils.parseFormatString(str)
+        assertEquals(listOf(
+                CodeUtils.LogDataTypes.BOOLEAN,
+                CodeUtils.LogDataTypes.LONG,
+                CodeUtils.LogDataTypes.LONG,
+                CodeUtils.LogDataTypes.LONG,
+                CodeUtils.LogDataTypes.DOUBLE,
+                CodeUtils.LogDataTypes.DOUBLE,
+                CodeUtils.LogDataTypes.DOUBLE,
+                CodeUtils.LogDataTypes.STRING
+        ), out)
+    }
+
+    @Test(expected = InvalidFormatStringException::class)
+    fun parseFormatString_invalid() {
+        val str = "%q"
+        CodeUtils.parseFormatString(str)
+    }
+
+    @Test
+    fun logDataTypesToBitMask() {
+        val types = listOf(CodeUtils.LogDataTypes.STRING, CodeUtils.LogDataTypes.DOUBLE,
+                CodeUtils.LogDataTypes.LONG, CodeUtils.LogDataTypes.BOOLEAN)
+        val mask = CodeUtils.logDataTypesToBitMask(types)
+        assertEquals(0b11011000, mask)
+    }
+
+    @Test(expected = InvalidFormatStringException::class)
+    fun logDataTypesToBitMask_toManyParams() {
+        val types = mutableListOf<CodeUtils.LogDataTypes>()
+        for (i in 0..16) {
+            types.add(CodeUtils.LogDataTypes.STRING)
+        }
+        CodeUtils.logDataTypesToBitMask(types)
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/CommandOptionsTest.kt b/tools/protologtool/tests/com/android/protologtool/CommandOptionsTest.kt
new file mode 100644
index 0000000..c1cd473
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/CommandOptionsTest.kt
@@ -0,0 +1,250 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CommandOptionsTest {
+    companion object {
+        val TEST_JAVA_SRC = listOf(
+                "frameworks/base/services/core/java/com/android/server/wm/" +
+                        "AccessibilityController.java",
+                "frameworks/base/services/core/java/com/android/server/wm/ActivityDisplay.java",
+                "frameworks/base/services/core/java/com/android/server/wm/" +
+                        "ActivityMetricsLaunchObserver.java"
+        )
+        private const val TEST_PROTOLOG_CLASS = "com.android.server.wm.ProtoLog"
+        private const val TEST_PROTOLOGIMPL_CLASS = "com.android.server.wm.ProtoLogImpl"
+        private const val TEST_PROTOLOGGROUP_CLASS = "com.android.server.wm.ProtoLogGroup"
+        private const val TEST_PROTOLOGGROUP_JAR = "out/soong/.intermediates/frameworks/base/" +
+                "services/core/services.core.wm.protologgroups/android_common/javac/" +
+                "services.core.wm.protologgroups.jar"
+        private const val TEST_SRC_JAR = "out/soong/.temp/sbox175955373/" +
+                "services.core.wm.protolog.srcjar"
+        private const val TEST_VIEWER_JSON = "out/soong/.temp/sbox175955373/" +
+                "services.core.wm.protolog.json"
+        private const val TEST_LOG = "./test_log.pb"
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun noCommand() {
+        CommandOptions(arrayOf())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun invalidCommand() {
+        val testLine = "invalid"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test
+    fun transformClasses() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        val cmd = CommandOptions(testLine.split(' ').toTypedArray())
+        assertEquals(CommandOptions.TRANSFORM_CALLS_CMD, cmd.command)
+        assertEquals(TEST_PROTOLOG_CLASS, cmd.protoLogClassNameArg)
+        assertEquals(TEST_PROTOLOGIMPL_CLASS, cmd.protoLogImplClassNameArg)
+        assertEquals(TEST_PROTOLOGGROUP_CLASS, cmd.protoLogGroupsClassNameArg)
+        assertEquals(TEST_PROTOLOGGROUP_JAR, cmd.protoLogGroupsJarArg)
+        assertEquals(TEST_SRC_JAR, cmd.outputSourceJarArg)
+        assertEquals(TEST_JAVA_SRC, cmd.javaSourceArgs)
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noProtoLogClass() {
+        val testLine = "transform-protolog-calls " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noProtoLogImplClass() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noProtoLogGroupClass() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noProtoLogGroupJar() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noOutJar() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                TEST_JAVA_SRC.joinToString(" ")
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noJavaInput() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidProtoLogClass() {
+        val testLine = "transform-protolog-calls --protolog-class invalid " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidProtoLogImplClass() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class invalid " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidProtoLogGroupClass() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class invalid " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidProtoLogGroupJar() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar invalid.txt " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidOutJar() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar invalid.db ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_invalidJavaInput() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR invalid.py"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_unknownParam() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--unknown test --protolog-impl-class $TEST_PROTOLOGIMPL_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun transformClasses_noValue() {
+        val testLine = "transform-protolog-calls --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--protolog-impl-class " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--output-srcjar $TEST_SRC_JAR ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test
+    fun generateConfig() {
+        val testLine = "generate-viewer-config --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--viewer-conf $TEST_VIEWER_JSON ${TEST_JAVA_SRC.joinToString(" ")}"
+        val cmd = CommandOptions(testLine.split(' ').toTypedArray())
+        assertEquals(CommandOptions.GENERATE_CONFIG_CMD, cmd.command)
+        assertEquals(TEST_PROTOLOG_CLASS, cmd.protoLogClassNameArg)
+        assertEquals(TEST_PROTOLOGGROUP_CLASS, cmd.protoLogGroupsClassNameArg)
+        assertEquals(TEST_PROTOLOGGROUP_JAR, cmd.protoLogGroupsJarArg)
+        assertEquals(TEST_VIEWER_JSON, cmd.viewerConfigJsonArg)
+        assertEquals(TEST_JAVA_SRC, cmd.javaSourceArgs)
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun generateConfig_noViewerConfig() {
+        val testLine = "generate-viewer-config --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                TEST_JAVA_SRC.joinToString(" ")
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test(expected = InvalidCommandException::class)
+    fun generateConfig_invalidViewerConfig() {
+        val testLine = "generate-viewer-config --protolog-class $TEST_PROTOLOG_CLASS " +
+                "--loggroups-class $TEST_PROTOLOGGROUP_CLASS " +
+                "--loggroups-jar $TEST_PROTOLOGGROUP_JAR " +
+                "--viewer-conf invalid.yaml ${TEST_JAVA_SRC.joinToString(" ")}"
+        CommandOptions(testLine.split(' ').toTypedArray())
+    }
+
+    @Test
+    fun readLog() {
+        val testLine = "read-log --viewer-conf $TEST_VIEWER_JSON $TEST_LOG"
+        val cmd = CommandOptions(testLine.split(' ').toTypedArray())
+        assertEquals(CommandOptions.READ_LOG_CMD, cmd.command)
+        assertEquals(TEST_VIEWER_JSON, cmd.viewerConfigJsonArg)
+        assertEquals(TEST_LOG, cmd.logProtofileArg)
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/LogParserTest.kt b/tools/protologtool/tests/com/android/protologtool/LogParserTest.kt
new file mode 100644
index 0000000..7106ea6
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/LogParserTest.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.json.stream.JsonReader
+import com.android.server.wm.ProtoLogMessage
+import com.android.server.wm.WindowManagerLogFileProto
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.PrintStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class LogParserTest {
+    private val configParser: ViewerConfigParser = mock(ViewerConfigParser::class.java)
+    private val parser = LogParser(configParser)
+    private var config: MutableMap<Int, ViewerConfigParser.ConfigEntry> = mutableMapOf()
+    private var outStream: OutputStream = ByteArrayOutputStream()
+    private var printStream: PrintStream = PrintStream(outStream)
+    private val dateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
+
+    @Before
+    fun init() {
+        Mockito.`when`(configParser.parseConfig(any(JsonReader::class.java))).thenReturn(config)
+    }
+
+    private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+
+    private fun getConfigDummyStream(): InputStream {
+        return "".byteInputStream()
+    }
+
+    private fun buildProtoInput(logBuilder: WindowManagerLogFileProto.Builder): InputStream {
+        logBuilder.setVersion(Constants.VERSION)
+        logBuilder.magicNumber =
+                WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_H.number.toLong() shl 32 or
+                        WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_L.number.toLong()
+        return logBuilder.build().toByteArray().inputStream()
+    }
+
+    private fun testDate(timeMS: Long): String {
+        return dateFormat.format(Date(timeMS))
+    }
+
+    @Test
+    fun parse() {
+        config[70933285] = ViewerConfigParser.ConfigEntry("Test completed successfully: %b",
+                "ERROR", "WindowManager")
+
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        val logMessageBuilder = ProtoLogMessage.newBuilder()
+        logMessageBuilder
+                .setMessageHash(70933285)
+                .setElapsedRealtimeNanos(0)
+                .addBooleanParams(true)
+        logBuilder.addLog(logMessageBuilder.build())
+
+        parser.parse(buildProtoInput(logBuilder), getConfigDummyStream(), printStream)
+
+        assertEquals("${testDate(0)} ERROR WindowManager: Test completed successfully: true\n",
+                outStream.toString())
+    }
+
+    @Test
+    fun parse_formatting() {
+        config[123] = ViewerConfigParser.ConfigEntry("Test completed successfully: %b %d %% %o" +
+                " %x %e %g %s %f", "ERROR", "WindowManager")
+
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        val logMessageBuilder = ProtoLogMessage.newBuilder()
+        logMessageBuilder
+                .setMessageHash(123)
+                .setElapsedRealtimeNanos(0)
+                .addBooleanParams(true)
+                .addAllSint64Params(listOf(1000, 20000, 300000))
+                .addAllDoubleParams(listOf(0.1, 0.00001, 1000.1))
+                .addStrParams("test")
+        logBuilder.addLog(logMessageBuilder.build())
+
+        parser.parse(buildProtoInput(logBuilder), getConfigDummyStream(), printStream)
+
+        assertEquals("${testDate(0)} ERROR WindowManager: Test completed successfully: " +
+                "true 1000 % 47040 493e0 1.000000e-01 1.00000e-05 test 1000.100000\n",
+                outStream.toString())
+    }
+
+    @Test
+    fun parse_invalidParamsTooMany() {
+        config[123] = ViewerConfigParser.ConfigEntry("Test completed successfully: %b %d %% %o",
+                "ERROR", "WindowManager")
+
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        val logMessageBuilder = ProtoLogMessage.newBuilder()
+        logMessageBuilder
+                .setMessageHash(123)
+                .setElapsedRealtimeNanos(0)
+                .addBooleanParams(true)
+                .addAllSint64Params(listOf(1000, 20000, 300000))
+                .addAllDoubleParams(listOf(0.1, 0.00001, 1000.1))
+                .addStrParams("test")
+        logBuilder.addLog(logMessageBuilder.build())
+
+        parser.parse(buildProtoInput(logBuilder), getConfigDummyStream(), printStream)
+
+        assertEquals("${testDate(0)} INVALID: 123 - [test] [1000, 20000, 300000] " +
+                "[0.1, 1.0E-5, 1000.1] [true]\n", outStream.toString())
+    }
+
+    @Test
+    fun parse_invalidParamsNotEnough() {
+        config[123] = ViewerConfigParser.ConfigEntry("Test completed successfully: %b %d %% %o" +
+                " %x %e %g %s %f", "ERROR", "WindowManager")
+
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        val logMessageBuilder = ProtoLogMessage.newBuilder()
+        logMessageBuilder
+                .setMessageHash(123)
+                .setElapsedRealtimeNanos(0)
+                .addBooleanParams(true)
+                .addStrParams("test")
+        logBuilder.addLog(logMessageBuilder.build())
+
+        parser.parse(buildProtoInput(logBuilder), getConfigDummyStream(), printStream)
+
+        assertEquals("${testDate(0)} INVALID: 123 - [test] [] [] [true]\n",
+                outStream.toString())
+    }
+
+    @Test(expected = InvalidInputException::class)
+    fun parse_invalidMagicNumber() {
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        logBuilder.setVersion(Constants.VERSION)
+        logBuilder.magicNumber = 0
+        val stream = logBuilder.build().toByteArray().inputStream()
+
+        parser.parse(stream, getConfigDummyStream(), printStream)
+    }
+
+    @Test(expected = InvalidInputException::class)
+    fun parse_invalidVersion() {
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        logBuilder.setVersion("invalid")
+        logBuilder.magicNumber =
+                WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_H.number.toLong() shl 32 or
+                        WindowManagerLogFileProto.MagicNumber.MAGIC_NUMBER_L.number.toLong()
+        val stream = logBuilder.build().toByteArray().inputStream()
+
+        parser.parse(stream, getConfigDummyStream(), printStream)
+    }
+
+    @Test
+    fun parse_noConfig() {
+        val logBuilder = WindowManagerLogFileProto.newBuilder()
+        val logMessageBuilder = ProtoLogMessage.newBuilder()
+        logMessageBuilder
+                .setMessageHash(70933285)
+                .setElapsedRealtimeNanos(0)
+                .addBooleanParams(true)
+        logBuilder.addLog(logMessageBuilder.build())
+
+        parser.parse(buildProtoInput(logBuilder), getConfigDummyStream(), printStream)
+
+        assertEquals("${testDate(0)} UNKNOWN: 70933285 - [] [] [] [true]\n",
+                outStream.toString())
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/ProtoLogCallProcessorTest.kt b/tools/protologtool/tests/com/android/protologtool/ProtoLogCallProcessorTest.kt
new file mode 100644
index 0000000..dcb1f7f
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/ProtoLogCallProcessorTest.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.ast.expr.MethodCallExpr
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProtoLogCallProcessorTest {
+    private data class LogCall(
+        val call: MethodCallExpr,
+        val messageString: String,
+        val level: LogLevel,
+        val group: LogGroup
+    )
+
+    private val groupMap: MutableMap<String, LogGroup> = mutableMapOf()
+    private val calls: MutableList<LogCall> = mutableListOf()
+    private val visitor = ProtoLogCallProcessor("org.example.ProtoLog", "org.example.ProtoLogGroup",
+            groupMap)
+    private val processor = object : ProtoLogCallVisitor {
+        override fun processCall(
+            call: MethodCallExpr,
+            messageString: String,
+            level: LogLevel,
+            group: LogGroup
+        ) {
+            calls.add(LogCall(call, messageString, level, group))
+        }
+    }
+
+    private fun checkCalls() {
+        assertEquals(1, calls.size)
+        val c = calls[0]
+        assertEquals("test %b", c.messageString)
+        assertEquals(groupMap["TEST"], c.group)
+        assertEquals(LogLevel.DEBUG, c.level)
+    }
+
+    @Test
+    fun process_samePackage() {
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                    ProtoLog.e(ProtoLogGroup.ERROR, "error %d", 1);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", true, false, "WindowManager")
+        groupMap["ERROR"] = LogGroup("ERROR", true, true, "WindowManagerERROR")
+        visitor.process(StaticJavaParser.parse(code), processor)
+        assertEquals(2, calls.size)
+        var c = calls[0]
+        assertEquals("test %b", c.messageString)
+        assertEquals(groupMap["TEST"], c.group)
+        assertEquals(LogLevel.DEBUG, c.level)
+        c = calls[1]
+        assertEquals("error %d", c.messageString)
+        assertEquals(groupMap["ERROR"], c.group)
+        assertEquals(LogLevel.ERROR, c.level)
+    }
+
+    @Test
+    fun process_imported() {
+        val code = """
+            package org.example2;
+
+            import org.example.ProtoLog;
+            import org.example.ProtoLogGroup;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", true, true, "WindowManager")
+        visitor.process(StaticJavaParser.parse(code), processor)
+        checkCalls()
+    }
+
+    @Test
+    fun process_importedStatic() {
+        val code = """
+            package org.example2;
+
+            import static org.example.ProtoLog.d;
+            import static org.example.ProtoLogGroup.TEST;
+
+            class Test {
+                void test() {
+                    d(TEST, "test %b", true);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", true, true, "WindowManager")
+        visitor.process(StaticJavaParser.parse(code), processor)
+        checkCalls()
+    }
+
+    @Test(expected = InvalidProtoLogCallException::class)
+    fun process_groupNotImported() {
+        val code = """
+            package org.example2;
+
+            import org.example.ProtoLog;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", true, true, "WindowManager")
+        visitor.process(StaticJavaParser.parse(code), processor)
+    }
+
+    @Test
+    fun process_protoLogNotImported() {
+        val code = """
+            package org.example2;
+
+            import org.example.ProtoLogGroup;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", true, true, "WindowManager")
+        visitor.process(StaticJavaParser.parse(code), processor)
+        assertEquals(0, calls.size)
+    }
+
+    @Test(expected = InvalidProtoLogCallException::class)
+    fun process_unknownGroup() {
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                }
+            }
+        """
+        visitor.process(StaticJavaParser.parse(code), processor)
+    }
+
+    @Test(expected = InvalidProtoLogCallException::class)
+    fun process_staticGroup() {
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(TEST, "test %b", true);
+                }
+            }
+        """
+        visitor.process(StaticJavaParser.parse(code), processor)
+    }
+
+    @Test(expected = InvalidProtoLogCallException::class)
+    fun process_badGroup() {
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(0, "test %b", true);
+                }
+            }
+        """
+        visitor.process(StaticJavaParser.parse(code), processor)
+    }
+
+    @Test(expected = InvalidProtoLogCallException::class)
+    fun process_invalidSignature() {
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d("test");
+                }
+            }
+        """
+        visitor.process(StaticJavaParser.parse(code), processor)
+    }
+
+    @Test
+    fun process_disabled() {
+        // Disabled groups are also processed.
+        val code = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.d(ProtoLogGroup.TEST, "test %b", true);
+                }
+            }
+        """
+        groupMap["TEST"] = LogGroup("TEST", false, true, "WindowManager")
+        visitor.process(StaticJavaParser.parse(code), processor)
+        checkCalls()
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/SourceTransformerTest.kt b/tools/protologtool/tests/com/android/protologtool/SourceTransformerTest.kt
new file mode 100644
index 0000000..7b8dd9a
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/SourceTransformerTest.kt
@@ -0,0 +1,373 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.expr.MethodCallExpr
+import com.github.javaparser.ast.stmt.IfStmt
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.mockito.Mockito
+
+class SourceTransformerTest {
+    companion object {
+        private const val PROTO_LOG_IMPL_PATH = "org.example.ProtoLogImpl"
+        private val TEST_CODE = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.w(TEST_GROUP, "test %d %f", 100, 0.1);
+                }
+            }
+            """.trimIndent()
+
+        private val TEST_CODE_MULTILINE = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.w(TEST_GROUP, "test %d %f " + 
+                    "abc %s\n test", 100,
+                     0.1, "test");
+                }
+            }
+            """.trimIndent()
+
+        private val TEST_CODE_NO_PARAMS = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    ProtoLog.w(TEST_GROUP, "test");
+                }
+            }
+            """.trimIndent()
+
+        /* ktlint-disable max-line-length */
+        private val TRANSFORMED_CODE_TEXT_ENABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (TEST_GROUP.isLogToAny()) { long protoLogParam0 = 100; double protoLogParam1 = 0.1; org.example.ProtoLogImpl.w(TEST_GROUP, 835524026, 9, "test %d %f", protoLogParam0, protoLogParam1); }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_MULTILINE_TEXT_ENABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (TEST_GROUP.isLogToAny()) { long protoLogParam0 = 100; double protoLogParam1 = 0.1; String protoLogParam2 = String.valueOf("test"); org.example.ProtoLogImpl.w(TEST_GROUP, -986393606, 9, "test %d %f " + "abc %s\n test", protoLogParam0, protoLogParam1, protoLogParam2); 
+            
+            }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_NO_PARAMS = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (TEST_GROUP.isLogToAny()) { org.example.ProtoLogImpl.w(TEST_GROUP, 1282022424, 0, "test", (Object[]) null); }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_TEXT_DISABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (TEST_GROUP.isLogToAny()) { long protoLogParam0 = 100; double protoLogParam1 = 0.1; org.example.ProtoLogImpl.w(TEST_GROUP, 835524026, 9, null, protoLogParam0, protoLogParam1); }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_MULTILINE_TEXT_DISABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (TEST_GROUP.isLogToAny()) { long protoLogParam0 = 100; double protoLogParam1 = 0.1; String protoLogParam2 = String.valueOf("test"); org.example.ProtoLogImpl.w(TEST_GROUP, -986393606, 9, null, protoLogParam0, protoLogParam1, protoLogParam2); 
+            
+            }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_DISABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (false) { /* TEST_GROUP is disabled */ ProtoLog.w(TEST_GROUP, "test %d %f", 100, 0.1); }
+                }
+            }
+            """.trimIndent()
+
+        private val TRANSFORMED_CODE_MULTILINE_DISABLED = """
+            package org.example;
+
+            class Test {
+                void test() {
+                    if (false) { /* TEST_GROUP is disabled */ ProtoLog.w(TEST_GROUP, "test %d %f " + "abc %s\n test", 100, 0.1, "test"); 
+            
+            }
+                }
+            }
+            """.trimIndent()
+        /* ktlint-enable max-line-length */
+    }
+
+    private val processor: ProtoLogCallProcessor = Mockito.mock(ProtoLogCallProcessor::class.java)
+    private val sourceJarWriter = SourceTransformer("org.example.ProtoLogImpl", processor)
+
+    private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+
+    @Test
+    fun processClass_textEnabled() {
+        val code = StaticJavaParser.parse(TEST_CODE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0], "test %d %f",
+                    LogLevel.WARN, LogGroup("TEST_GROUP", true, true, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("TEST_GROUP.${Constants.IS_LOG_TO_ANY_METHOD}()",
+                ifStmt.condition.toString())
+        assertFalse(ifStmt.elseStmt.isPresent)
+        assertEquals(3, ifStmt.thenStmt.childNodes.size)
+        val methodCall = ifStmt.thenStmt.findAll(MethodCallExpr::class.java)[0] as MethodCallExpr
+        assertEquals(PROTO_LOG_IMPL_PATH, methodCall.scope.get().toString())
+        assertEquals("w", methodCall.name.asString())
+        assertEquals(6, methodCall.arguments.size)
+        assertEquals("TEST_GROUP", methodCall.arguments[0].toString())
+        assertEquals("835524026", methodCall.arguments[1].toString())
+        assertEquals(0b1001.toString(), methodCall.arguments[2].toString())
+        assertEquals("\"test %d %f\"", methodCall.arguments[3].toString())
+        assertEquals("protoLogParam0", methodCall.arguments[4].toString())
+        assertEquals("protoLogParam1", methodCall.arguments[5].toString())
+        assertEquals(TRANSFORMED_CODE_TEXT_ENABLED, out)
+    }
+
+    @Test
+    fun processClass_textEnabledMultiline() {
+        val code = StaticJavaParser.parse(TEST_CODE_MULTILINE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0],
+                    "test %d %f abc %s\n test", LogLevel.WARN, LogGroup("TEST_GROUP",
+                    true, true, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("TEST_GROUP.${Constants.IS_LOG_TO_ANY_METHOD}()",
+                ifStmt.condition.toString())
+        assertFalse(ifStmt.elseStmt.isPresent)
+        assertEquals(4, ifStmt.thenStmt.childNodes.size)
+        val methodCall = ifStmt.thenStmt.findAll(MethodCallExpr::class.java)[1] as MethodCallExpr
+        assertEquals(PROTO_LOG_IMPL_PATH, methodCall.scope.get().toString())
+        assertEquals("w", methodCall.name.asString())
+        assertEquals(7, methodCall.arguments.size)
+        assertEquals("TEST_GROUP", methodCall.arguments[0].toString())
+        assertEquals("-986393606", methodCall.arguments[1].toString())
+        assertEquals(0b001001.toString(), methodCall.arguments[2].toString())
+        assertEquals("protoLogParam0", methodCall.arguments[4].toString())
+        assertEquals("protoLogParam1", methodCall.arguments[5].toString())
+        assertEquals("protoLogParam2", methodCall.arguments[6].toString())
+        assertEquals(TRANSFORMED_CODE_MULTILINE_TEXT_ENABLED, out)
+    }
+
+    @Test
+    fun processClass_noParams() {
+        val code = StaticJavaParser.parse(TEST_CODE_NO_PARAMS)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0], "test",
+                    LogLevel.WARN, LogGroup("TEST_GROUP", true, true, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("TEST_GROUP.${Constants.IS_LOG_TO_ANY_METHOD}()",
+                ifStmt.condition.toString())
+        assertFalse(ifStmt.elseStmt.isPresent)
+        assertEquals(1, ifStmt.thenStmt.childNodes.size)
+        val methodCall = ifStmt.thenStmt.findAll(MethodCallExpr::class.java)[0] as MethodCallExpr
+        assertEquals(PROTO_LOG_IMPL_PATH, methodCall.scope.get().toString())
+        assertEquals("w", methodCall.name.asString())
+        assertEquals(5, methodCall.arguments.size)
+        assertEquals("TEST_GROUP", methodCall.arguments[0].toString())
+        assertEquals("1282022424", methodCall.arguments[1].toString())
+        assertEquals(0.toString(), methodCall.arguments[2].toString())
+        assertEquals(TRANSFORMED_CODE_NO_PARAMS, out)
+    }
+
+    @Test
+    fun processClass_textDisabled() {
+        val code = StaticJavaParser.parse(TEST_CODE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0], "test %d %f",
+                    LogLevel.WARN, LogGroup("TEST_GROUP", true, false, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("TEST_GROUP.${Constants.IS_LOG_TO_ANY_METHOD}()",
+                ifStmt.condition.toString())
+        assertFalse(ifStmt.elseStmt.isPresent)
+        assertEquals(3, ifStmt.thenStmt.childNodes.size)
+        val methodCall = ifStmt.thenStmt.findAll(MethodCallExpr::class.java)[0] as MethodCallExpr
+        assertEquals(PROTO_LOG_IMPL_PATH, methodCall.scope.get().toString())
+        assertEquals("w", methodCall.name.asString())
+        assertEquals(6, methodCall.arguments.size)
+        assertEquals("TEST_GROUP", methodCall.arguments[0].toString())
+        assertEquals("835524026", methodCall.arguments[1].toString())
+        assertEquals(0b1001.toString(), methodCall.arguments[2].toString())
+        assertEquals("null", methodCall.arguments[3].toString())
+        assertEquals("protoLogParam0", methodCall.arguments[4].toString())
+        assertEquals("protoLogParam1", methodCall.arguments[5].toString())
+        assertEquals(TRANSFORMED_CODE_TEXT_DISABLED, out)
+    }
+
+    @Test
+    fun processClass_textDisabledMultiline() {
+        val code = StaticJavaParser.parse(TEST_CODE_MULTILINE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0],
+                    "test %d %f abc %s\n test", LogLevel.WARN, LogGroup("TEST_GROUP",
+                    true, false, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("TEST_GROUP.${Constants.IS_LOG_TO_ANY_METHOD}()",
+                ifStmt.condition.toString())
+        assertFalse(ifStmt.elseStmt.isPresent)
+        assertEquals(4, ifStmt.thenStmt.childNodes.size)
+        val methodCall = ifStmt.thenStmt.findAll(MethodCallExpr::class.java)[1] as MethodCallExpr
+        assertEquals(PROTO_LOG_IMPL_PATH, methodCall.scope.get().toString())
+        assertEquals("w", methodCall.name.asString())
+        assertEquals(7, methodCall.arguments.size)
+        assertEquals("TEST_GROUP", methodCall.arguments[0].toString())
+        assertEquals("-986393606", methodCall.arguments[1].toString())
+        assertEquals(0b001001.toString(), methodCall.arguments[2].toString())
+        assertEquals("null", methodCall.arguments[3].toString())
+        assertEquals("protoLogParam0", methodCall.arguments[4].toString())
+        assertEquals("protoLogParam1", methodCall.arguments[5].toString())
+        assertEquals("protoLogParam2", methodCall.arguments[6].toString())
+        assertEquals(TRANSFORMED_CODE_MULTILINE_TEXT_DISABLED, out)
+    }
+
+    @Test
+    fun processClass_disabled() {
+        val code = StaticJavaParser.parse(TEST_CODE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0], "test %d %f",
+                    LogLevel.WARN, LogGroup("TEST_GROUP", false, true, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("false", ifStmt.condition.toString())
+        assertEquals(TRANSFORMED_CODE_DISABLED, out)
+    }
+
+    @Test
+    fun processClass_disabledMultiline() {
+        val code = StaticJavaParser.parse(TEST_CODE_MULTILINE)
+
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(code.findAll(MethodCallExpr::class.java)[0],
+                    "test %d %f abc %s\n test", LogLevel.WARN, LogGroup("TEST_GROUP",
+                    false, true, "WM_TEST"))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        val out = sourceJarWriter.processClass(code)
+
+        val ifStmts = code.findAll(IfStmt::class.java)
+        assertEquals(1, ifStmts.size)
+        val ifStmt = ifStmts[0]
+        assertEquals("false", ifStmt.condition.toString())
+        assertEquals(TRANSFORMED_CODE_MULTILINE_DISABLED, out)
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/ViewerConfigBuilderTest.kt b/tools/protologtool/tests/com/android/protologtool/ViewerConfigBuilderTest.kt
new file mode 100644
index 0000000..53d2e8b
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/ViewerConfigBuilderTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.json.stream.JsonReader
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.expr.MethodCallExpr
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mockito.Mockito
+import java.io.StringReader
+
+class ViewerConfigBuilderTest {
+    companion object {
+        private val TAG1 = "WM_TEST"
+        private val TAG2 = "WM_DEBUG"
+        private val TEST1 = ViewerConfigParser.ConfigEntry("test1", LogLevel.INFO.name, TAG1)
+        private val TEST2 = ViewerConfigParser.ConfigEntry("test2", LogLevel.DEBUG.name, TAG2)
+        private val TEST3 = ViewerConfigParser.ConfigEntry("test3", LogLevel.ERROR.name, TAG2)
+    }
+
+    private val processor: ProtoLogCallProcessor = Mockito.mock(ProtoLogCallProcessor::class.java)
+    private val configBuilder = ViewerConfigBuilder(processor)
+    private val dummyCompilationUnit = CompilationUnit()
+
+    private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+
+    private fun parseConfig(json: String): Map<Int, ViewerConfigParser.ConfigEntry> {
+        return ViewerConfigParser().parseConfig(JsonReader(StringReader(json)))
+    }
+
+    @Test
+    fun processClass() {
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(MethodCallExpr(), TEST1.messageString, LogLevel.INFO,
+                    LogGroup("TEST_GROUP", true, true, TAG1))
+            visitor.processCall(MethodCallExpr(), TEST2.messageString, LogLevel.DEBUG,
+                    LogGroup("DEBUG_GROUP", true, true, TAG2))
+            visitor.processCall(MethodCallExpr(), TEST3.messageString, LogLevel.ERROR,
+                    LogGroup("DEBUG_GROUP", true, true, TAG2))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        configBuilder.processClass(dummyCompilationUnit)
+
+        val parsedConfig = parseConfig(configBuilder.build())
+        assertEquals(3, parsedConfig.size)
+        assertEquals(TEST1, parsedConfig[CodeUtils.hash(TEST1.messageString,
+                LogLevel.INFO)])
+        assertEquals(TEST2, parsedConfig[CodeUtils.hash(TEST2.messageString,
+                LogLevel.DEBUG)])
+        assertEquals(TEST3, parsedConfig[CodeUtils.hash(TEST3.messageString,
+                LogLevel.ERROR)])
+    }
+
+    @Test
+    fun processClass_nonUnique() {
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(MethodCallExpr(), TEST1.messageString, LogLevel.INFO,
+                    LogGroup("TEST_GROUP", true, true, TAG1))
+            visitor.processCall(MethodCallExpr(), TEST1.messageString, LogLevel.INFO,
+                    LogGroup("TEST_GROUP", true, true, TAG1))
+            visitor.processCall(MethodCallExpr(), TEST1.messageString, LogLevel.INFO,
+                    LogGroup("TEST_GROUP", true, true, TAG1))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        configBuilder.processClass(dummyCompilationUnit)
+
+        val parsedConfig = parseConfig(configBuilder.build())
+        assertEquals(1, parsedConfig.size)
+        assertEquals(TEST1, parsedConfig[CodeUtils.hash(TEST1.messageString, LogLevel.INFO)])
+    }
+
+    @Test
+    fun processClass_disabled() {
+        Mockito.`when`(processor.process(any(CompilationUnit::class.java),
+                any(ProtoLogCallVisitor::class.java))).thenAnswer { invocation ->
+            val visitor = invocation.arguments[1] as ProtoLogCallVisitor
+
+            visitor.processCall(MethodCallExpr(), TEST1.messageString, LogLevel.INFO,
+                    LogGroup("TEST_GROUP", true, true, TAG1))
+            visitor.processCall(MethodCallExpr(), TEST2.messageString, LogLevel.DEBUG,
+                    LogGroup("DEBUG_GROUP", false, true, TAG2))
+            visitor.processCall(MethodCallExpr(), TEST3.messageString, LogLevel.ERROR,
+                    LogGroup("DEBUG_GROUP", true, false, TAG2))
+
+            invocation.arguments[0] as CompilationUnit
+        }
+
+        configBuilder.processClass(dummyCompilationUnit)
+
+        val parsedConfig = parseConfig(configBuilder.build())
+        assertEquals(2, parsedConfig.size)
+        assertEquals(TEST1, parsedConfig[CodeUtils.hash(TEST1.messageString, LogLevel.INFO)])
+        assertEquals(TEST3, parsedConfig[CodeUtils.hash(TEST3.messageString, LogLevel.ERROR)])
+    }
+}
diff --git a/tools/protologtool/tests/com/android/protologtool/ViewerConfigParserTest.kt b/tools/protologtool/tests/com/android/protologtool/ViewerConfigParserTest.kt
new file mode 100644
index 0000000..c0cea73
--- /dev/null
+++ b/tools/protologtool/tests/com/android/protologtool/ViewerConfigParserTest.kt
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.protologtool
+
+import com.android.json.stream.JsonReader
+import org.junit.Test
+import java.io.StringReader
+import org.junit.Assert.assertEquals
+
+class ViewerConfigParserTest {
+    private val parser = ViewerConfigParser()
+
+    private fun getJSONReader(str: String): JsonReader {
+        return JsonReader(StringReader(str))
+    }
+
+    @Test
+    fun parseMessage() {
+        val json = """
+        {
+            "message": "Test completed successfully: %b",
+            "level": "ERROR",
+            "group": "GENERIC_WM"
+        }
+        """
+        val msg = parser.parseMessage(getJSONReader(json))
+        assertEquals("Test completed successfully: %b", msg.messageString)
+        assertEquals("ERROR", msg.level)
+        assertEquals("GENERIC_WM", msg.groupName)
+    }
+
+    @Test
+    fun parseMessage_reorder() {
+        val json = """
+        {
+            "group": "GENERIC_WM",
+            "level": "ERROR",
+            "message": "Test completed successfully: %b"
+        }
+        """
+        val msg = parser.parseMessage(getJSONReader(json))
+        assertEquals("Test completed successfully: %b", msg.messageString)
+        assertEquals("ERROR", msg.level)
+        assertEquals("GENERIC_WM", msg.groupName)
+    }
+
+    @Test
+    fun parseMessage_unknownEntry() {
+        val json = """
+        {
+            "unknown": "unknown entries should not block parsing",
+            "message": "Test completed successfully: %b",
+            "level": "ERROR",
+            "group": "GENERIC_WM"
+        }
+        """
+        val msg = parser.parseMessage(getJSONReader(json))
+        assertEquals("Test completed successfully: %b", msg.messageString)
+        assertEquals("ERROR", msg.level)
+        assertEquals("GENERIC_WM", msg.groupName)
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseMessage_noMessage() {
+        val json = """
+        {
+            "level": "ERROR",
+            "group": "GENERIC_WM"
+        }
+        """
+        parser.parseMessage(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseMessage_noLevel() {
+        val json = """
+        {
+            "message": "Test completed successfully: %b",
+            "group": "GENERIC_WM"
+        }
+        """
+        parser.parseMessage(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseMessage_noGroup() {
+        val json = """
+        {
+            "message": "Test completed successfully: %b",
+            "level": "ERROR"
+        }
+        """
+        parser.parseMessage(getJSONReader(json))
+    }
+
+    @Test
+    fun parseGroup() {
+        val json = """
+        {
+            "tag": "WindowManager"
+        }
+        """
+        val group = parser.parseGroup(getJSONReader(json))
+        assertEquals("WindowManager", group.tag)
+    }
+
+    @Test
+    fun parseGroup_unknownEntry() {
+        val json = """
+        {
+            "unknown": "unknown entries should not block parsing",
+            "tag": "WindowManager"
+        }
+        """
+        val group = parser.parseGroup(getJSONReader(json))
+        assertEquals("WindowManager", group.tag)
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseGroup_noTag() {
+        val json = """
+        {
+        }
+        """
+        parser.parseGroup(getJSONReader(json))
+    }
+
+    @Test
+    fun parseMessages() {
+        val json = """
+        {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            },
+            "1792430067": {
+              "message": "Attempted to add window to a display that does not exist: %d. Aborting.",
+              "level": "WARN",
+              "group": "ERROR_WM"
+            }
+        }
+        """
+        val messages = parser.parseMessages(getJSONReader(json))
+        assertEquals(2, messages.size)
+        val msg1 =
+                ViewerConfigParser.MessageEntry("Test completed successfully: %b",
+                        "ERROR", "GENERIC_WM")
+        val msg2 =
+                ViewerConfigParser.MessageEntry("Attempted to add window to a display that " +
+                        "does not exist: %d. Aborting.", "WARN", "ERROR_WM")
+
+        assertEquals(msg1, messages[70933285])
+        assertEquals(msg2, messages[1792430067])
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseMessages_invalidHash() {
+        val json = """
+        {
+            "invalid": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+        }
+        """
+        parser.parseMessages(getJSONReader(json))
+    }
+
+    @Test
+    fun parseGroups() {
+        val json = """
+        {
+            "GENERIC_WM": {
+              "tag": "WindowManager"
+            },
+            "ERROR_WM": {
+              "tag": "WindowManagerError"
+            }
+        }
+        """
+        val groups = parser.parseGroups(getJSONReader(json))
+        assertEquals(2, groups.size)
+        val grp1 = ViewerConfigParser.GroupEntry("WindowManager")
+        val grp2 = ViewerConfigParser.GroupEntry("WindowManagerError")
+        assertEquals(grp1, groups["GENERIC_WM"])
+        assertEquals(grp2, groups["ERROR_WM"])
+    }
+
+    @Test
+    fun parseConfig() {
+        val json = """
+        {
+          "version": "${Constants.VERSION}",
+          "messages": {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+          },
+          "groups": {
+            "GENERIC_WM": {
+              "tag": "WindowManager"
+            }
+          }
+        }
+        """
+        val config = parser.parseConfig(getJSONReader(json))
+        assertEquals(1, config.size)
+        val cfg1 = ViewerConfigParser.ConfigEntry("Test completed successfully: %b",
+                "ERROR", "WindowManager")
+        assertEquals(cfg1, config[70933285])
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseConfig_invalidVersion() {
+        val json = """
+        {
+          "version": "invalid",
+          "messages": {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+          },
+          "groups": {
+            "GENERIC_WM": {
+              "tag": "WindowManager"
+            }
+          }
+        }
+        """
+        parser.parseConfig(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseConfig_noVersion() {
+        val json = """
+        {
+          "messages": {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+          },
+          "groups": {
+            "GENERIC_WM": {
+              "tag": "WindowManager"
+            }
+          }
+        }
+        """
+        parser.parseConfig(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseConfig_noMessages() {
+        val json = """
+        {
+          "version": "${Constants.VERSION}",
+          "groups": {
+            "GENERIC_WM": {
+              "tag": "WindowManager"
+            }
+          }
+        }
+        """
+        parser.parseConfig(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseConfig_noGroups() {
+        val json = """
+        {
+          "version": "${Constants.VERSION}",
+          "messages": {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+          }
+        }
+        """
+        parser.parseConfig(getJSONReader(json))
+    }
+
+    @Test(expected = InvalidViewerConfigException::class)
+    fun parseConfig_missingGroup() {
+        val json = """
+        {
+          "version": "${Constants.VERSION}",
+          "messages": {
+            "70933285": {
+              "message": "Test completed successfully: %b",
+              "level": "ERROR",
+              "group": "GENERIC_WM"
+            }
+          },
+          "groups": {
+            "ERROR_WM": {
+              "tag": "WindowManager"
+            }
+          }
+        }
+        """
+        val config = parser.parseConfig(getJSONReader(json))
+    }
+}