Move ProtoLog tests to seperate test directory dedicated to tracing tests

Bug: 364255103
Flag: TEST_ONLY
Test: atest TracingTests
Change-Id: I1f53100c03e71647746d2214f1bf23ec30ec3129
diff --git a/tests/Tracing/Android.bp b/tests/Tracing/Android.bp
new file mode 100644
index 0000000..5a7f12f
--- /dev/null
+++ b/tests/Tracing/Android.bp
@@ -0,0 +1,33 @@
+package {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_team: "trendy_team_windowing_tools",
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_test {
+    name: "TracingTests",
+    proto: {
+        type: "nano",
+    },
+    // Include some source files directly to be able to access package members
+    srcs: ["src/**/*.java"],
+    libs: ["android.test.runner"],
+    static_libs: [
+        "junit",
+        "androidx.test.rules",
+        "mockito-target-minus-junit4",
+        "truth",
+        "platform-test-annotations",
+        "flickerlib-parsers",
+        "perfetto_trace_java_protos",
+        "flickerlib-trace_processor_shell",
+    ],
+    java_resource_dirs: ["res"],
+    certificate: "platform",
+    platform_apis: true,
+    test_suites: ["device-tests"],
+}
diff --git a/tests/Tracing/AndroidManifest.xml b/tests/Tracing/AndroidManifest.xml
new file mode 100644
index 0000000..7254f81
--- /dev/null
+++ b/tests/Tracing/AndroidManifest.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+     package="com.android.tracing.tests">
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+    <uses-permission android:name="android.permission.BIND_WALLPAPER"/>
+    <!-- Allow the test to connect to perfetto trace processor -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <application
+        android:requestLegacyExternalStorage="true"
+        android:networkSecurityConfig="@xml/network_security_config">
+        <uses-library android:name="android.test.runner"/>
+    </application>
+
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+         android:targetPackage="com.android.tracing.tests"
+         android:label="Tracing Tests"/>
+</manifest>
diff --git a/tests/Tracing/AndroidTest.xml b/tests/Tracing/AndroidTest.xml
new file mode 100644
index 0000000..9a40420
--- /dev/null
+++ b/tests/Tracing/AndroidTest.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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
+  -->
+<configuration description="Runs tests for tracing classes/utilities.">
+    <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+        <option name="test-file-name" value="TracingTests.apk" />
+    </target_preparer>
+
+    <!-- Needed for pushing the trace config file -->
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer"/>
+
+    <option name="test-suite-tag" value="apct" />
+    <option name="test-suite-tag" value="framework-base-presubmit" />
+    <option name="test-tag" value="TracingTests" />
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.tracing.tests" />
+        <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+    </test>
+
+    <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
+        <option name="pull-pattern-keys" value="perfetto_file_path"/>
+        <option name="directory-keys"
+            value="/data/user/0/com.android.tracing.tests/files"/>
+        <option name="collect-on-run-ended-only" value="true"/>
+        <option name="clean-up" value="true"/>
+    </metrics_collector>
+</configuration>
\ No newline at end of file
diff --git a/tests/Tracing/OWNERS b/tests/Tracing/OWNERS
new file mode 100644
index 0000000..4a50338
--- /dev/null
+++ b/tests/Tracing/OWNERS
@@ -0,0 +1,3 @@
+# Tracing owners
+# Bug component: 1157642
+include platform/development:/tools/winscope/OWNERS
diff --git a/tests/Tracing/TEST_MAPPING b/tests/Tracing/TEST_MAPPING
new file mode 100644
index 0000000..7f58fce
--- /dev/null
+++ b/tests/Tracing/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "postsubmit": [
+    {
+      "name": "TracingTests"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/Tracing/res/xml/network_security_config.xml b/tests/Tracing/res/xml/network_security_config.xml
new file mode 100644
index 0000000..fdf1dbb
--- /dev/null
+++ b/tests/Tracing/res/xml/network_security_config.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 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.
+  -->
+<network-security-config>
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">localhost</domain>
+    </domain-config>
+</network-security-config>
diff --git a/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogImplTest.java b/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogImplTest.java
new file mode 100644
index 0000000..8913e8c
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogImplTest.java
@@ -0,0 +1,407 @@
+/*
+ * 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.protolog;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static com.android.internal.protolog.LegacyProtoLogImpl.PROTOLOG_VERSION;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.platform.test.annotations.Presubmit;
+import android.util.proto.ProtoInputStream;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.protolog.common.IProtoLogGroup;
+import com.android.internal.protolog.common.LogLevel;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintWriter;
+import java.util.LinkedList;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@SuppressWarnings("ConstantConditions")
+@SmallTest
+@Presubmit
+@RunWith(JUnit4.class)
+public class LegacyProtoLogImplTest {
+
+    private static final byte[] MAGIC_HEADER = new byte[]{
+            0x9, 0x50, 0x52, 0x4f, 0x54, 0x4f, 0x4c, 0x4f, 0x47
+    };
+
+    private LegacyProtoLogImpl mProtoLog;
+    private File mFile;
+
+    @Mock
+    private LegacyProtoLogViewerConfigReader mReader;
+
+    private final String mViewerConfigFilename = "unused/file/path";
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        final Context testContext = getInstrumentation().getContext();
+        mFile = testContext.getFileStreamPath("tracing_test.dat");
+        //noinspection ResultOfMethodCallIgnored
+        mFile.delete();
+        mProtoLog = new LegacyProtoLogImpl(mFile, mViewerConfigFilename,
+                1024 * 1024, mReader, 1024, () -> {});
+    }
+
+    @After
+    public void tearDown() {
+        if (mFile != null) {
+            //noinspection ResultOfMethodCallIgnored
+            mFile.delete();
+        }
+        ProtoLogImpl.setSingleInstance(null);
+    }
+
+    @Test
+    public void isEnabled_returnsFalseByDefault() {
+        assertFalse(mProtoLog.isProtoEnabled());
+    }
+
+    @Test
+    public void isEnabled_returnsTrueAfterStart() {
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        assertTrue(mProtoLog.isProtoEnabled());
+    }
+
+    @Test
+    public void isEnabled_returnsFalseAfterStop() {
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        mProtoLog.stopProtoLog(mock(PrintWriter.class), true);
+        assertFalse(mProtoLog.isProtoEnabled());
+    }
+
+    @Test
+    public void logFile_startsWithMagicHeader() throws Exception {
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        mProtoLog.stopProtoLog(mock(PrintWriter.class), true);
+
+        assertTrue("Log file should exist", mFile.exists());
+
+        byte[] header = new byte[MAGIC_HEADER.length];
+        try (InputStream is = new FileInputStream(mFile)) {
+            assertEquals(MAGIC_HEADER.length, is.read(header));
+            assertArrayEquals(MAGIC_HEADER, header);
+        }
+    }
+
+    @Test
+    public void log_logcatEnabledExternalMessage() {
+        when(mReader.getViewerString(anyLong())).thenReturn("test %b %d %% 0x%x %s %f");
+        LegacyProtoLogImpl implSpy = Mockito.spy(mProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{true, 10000, 30000, "test", 0.000003});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO),
+                eq("test true 10000 % 0x7530 test 3.0E-6"));
+        verify(mReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatEnabledInvalidMessage() {
+        when(mReader.getViewerString(anyLong())).thenReturn("test %b %d %% %x %s %f");
+        LegacyProtoLogImpl implSpy = Mockito.spy(mProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{true, 10000, 0.0001, 0.00002, "test"});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO),
+                eq("UNKNOWN MESSAGE (1234) true 10000 1.0E-4 2.0E-5 test"));
+        verify(mReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatEnabledInlineMessage() {
+        when(mReader.getViewerString(anyLong())).thenReturn("test %d");
+        LegacyProtoLogImpl implSpy = Mockito.spy(mProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{5});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO), eq("test 5"));
+    }
+
+    @Test
+    public void log_logcatEnabledNoMessage() {
+        when(mReader.getViewerString(anyLong())).thenReturn(null);
+        LegacyProtoLogImpl implSpy = Mockito.spy(mProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{5});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO), eq("UNKNOWN MESSAGE (1234) 5"));
+        verify(mReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatDisabled() {
+        when(mReader.getViewerString(anyLong())).thenReturn("test %d");
+        LegacyProtoLogImpl implSpy = Mockito.spy(mProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{5});
+
+        verify(implSpy, never()).passToLogcat(any(), any(), any());
+        verify(mReader, never()).getViewerString(anyLong());
+    }
+
+    @Test
+    public void loadViewerConfigOnLogcatGroupRegistration() {
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        mProtoLog.registerGroups(TestProtoLogGroup.TEST_GROUP);
+        verify(mReader).loadViewerConfig(any(), any());
+    }
+
+    private static class ProtoLogData {
+        Long mMessageHash = null;
+        Long mElapsedTime = null;
+        LinkedList<String> mStrParams = new LinkedList<>();
+        LinkedList<Long> mSint64Params = new LinkedList<>();
+        LinkedList<Double> mDoubleParams = new LinkedList<>();
+        LinkedList<Boolean> mBooleanParams = new LinkedList<>();
+    }
+
+    private ProtoLogData readProtoLogSingle(ProtoInputStream ip) throws IOException {
+        while (ip.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
+            if (ip.getFieldNumber() == (int) ProtoLogFileProto.VERSION) {
+                assertEquals(PROTOLOG_VERSION, ip.readString(ProtoLogFileProto.VERSION));
+                continue;
+            }
+            if (ip.getFieldNumber() != (int) ProtoLogFileProto.LOG) {
+                continue;
+            }
+            long token = ip.start(ProtoLogFileProto.LOG);
+            ProtoLogData data = new ProtoLogData();
+            while (ip.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
+                switch (ip.getFieldNumber()) {
+                    case (int) ProtoLogMessage.MESSAGE_HASH: {
+                        data.mMessageHash = ip.readLong(ProtoLogMessage.MESSAGE_HASH);
+                        break;
+                    }
+                    case (int) ProtoLogMessage.ELAPSED_REALTIME_NANOS: {
+                        data.mElapsedTime = ip.readLong(ProtoLogMessage.ELAPSED_REALTIME_NANOS);
+                        break;
+                    }
+                    case (int) ProtoLogMessage.STR_PARAMS: {
+                        data.mStrParams.add(ip.readString(ProtoLogMessage.STR_PARAMS));
+                        break;
+                    }
+                    case (int) ProtoLogMessage.SINT64_PARAMS: {
+                        data.mSint64Params.add(ip.readLong(ProtoLogMessage.SINT64_PARAMS));
+                        break;
+                    }
+                    case (int) ProtoLogMessage.DOUBLE_PARAMS: {
+                        data.mDoubleParams.add(ip.readDouble(ProtoLogMessage.DOUBLE_PARAMS));
+                        break;
+                    }
+                    case (int) ProtoLogMessage.BOOLEAN_PARAMS: {
+                        data.mBooleanParams.add(ip.readBoolean(ProtoLogMessage.BOOLEAN_PARAMS));
+                        break;
+                    }
+                }
+            }
+            ip.end(token);
+            return data;
+        }
+        return null;
+    }
+
+    @Test
+    public void log_protoEnabled() throws Exception {
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(true);
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        long before = SystemClock.elapsedRealtimeNanos();
+        mProtoLog.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234,
+                0b1110101001010100,
+                new Object[]{"test", 1, 2, 3, 0.4, 0.5, 0.6, true});
+        long after = SystemClock.elapsedRealtimeNanos();
+        mProtoLog.stopProtoLog(mock(PrintWriter.class), true);
+        try (InputStream is = new FileInputStream(mFile)) {
+            ProtoInputStream ip = new ProtoInputStream(is);
+            ProtoLogData data = readProtoLogSingle(ip);
+            assertNotNull(data);
+            assertEquals(1234, data.mMessageHash.longValue());
+            assertTrue(before <= data.mElapsedTime && data.mElapsedTime <= after);
+            assertArrayEquals(new String[]{"test"}, data.mStrParams.toArray());
+            assertArrayEquals(new Long[]{1L, 2L, 3L}, data.mSint64Params.toArray());
+            assertArrayEquals(new Double[]{0.4, 0.5, 0.6}, data.mDoubleParams.toArray());
+            assertArrayEquals(new Boolean[]{true}, data.mBooleanParams.toArray());
+        }
+    }
+
+    @Test
+    public void log_invalidParamsMask() throws Exception {
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(true);
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        long before = SystemClock.elapsedRealtimeNanos();
+        mProtoLog.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234,
+                0b01100100,
+                new Object[]{"test", 1, 0.1, true});
+        long after = SystemClock.elapsedRealtimeNanos();
+        mProtoLog.stopProtoLog(mock(PrintWriter.class), true);
+        try (InputStream is = new FileInputStream(mFile)) {
+            ProtoInputStream ip = new ProtoInputStream(is);
+            ProtoLogData data = readProtoLogSingle(ip);
+            assertNotNull(data);
+            assertEquals(1234, data.mMessageHash.longValue());
+            assertTrue(before <= data.mElapsedTime && data.mElapsedTime <= after);
+            assertArrayEquals(new String[]{"test", "(INVALID PARAMS_MASK) true"},
+                    data.mStrParams.toArray());
+            assertArrayEquals(new Long[]{1L}, data.mSint64Params.toArray());
+            assertArrayEquals(new Double[]{0.1}, data.mDoubleParams.toArray());
+            assertArrayEquals(new Boolean[]{}, data.mBooleanParams.toArray());
+        }
+    }
+
+    @Test
+    public void log_protoDisabled() throws Exception {
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+        mProtoLog.startProtoLog(mock(PrintWriter.class));
+        mProtoLog.log(LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234,
+                0b11, new Object[]{true});
+        mProtoLog.stopProtoLog(mock(PrintWriter.class), true);
+        try (InputStream is = new FileInputStream(mFile)) {
+            ProtoInputStream ip = new ProtoInputStream(is);
+            ProtoLogData data = readProtoLogSingle(ip);
+            assertNull(data);
+        }
+    }
+
+    private enum TestProtoLogGroup implements IProtoLogGroup {
+        TEST_GROUP(true, true, false, "WindowManagetProtoLogTest");
+
+        private final boolean mEnabled;
+        private volatile boolean mLogToProto;
+        private volatile boolean mLogToLogcat;
+        private final String mTag;
+
+        /**
+         * @param enabled     set to false to exclude all log statements for this group from
+         *                    compilation,
+         *                    they will not be available in runtime.
+         * @param logToProto  enable binary logging for the group
+         * @param logToLogcat enable text logging for the group
+         * @param tag         name of the source of the logged message
+         */
+        TestProtoLogGroup(boolean enabled, boolean logToProto, boolean logToLogcat, String tag) {
+            this.mEnabled = enabled;
+            this.mLogToProto = logToProto;
+            this.mLogToLogcat = logToLogcat;
+            this.mTag = tag;
+        }
+
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        @Override
+        public boolean isLogToProto() {
+            return mLogToProto;
+        }
+
+        @Override
+        public boolean isLogToLogcat() {
+            return mLogToLogcat;
+        }
+
+        @Override
+        public boolean isLogToAny() {
+            return mLogToLogcat || mLogToProto;
+        }
+
+        @Override
+        public String getTag() {
+            return mTag;
+        }
+
+        @Override
+        public void setLogToProto(boolean logToProto) {
+            this.mLogToProto = logToProto;
+        }
+
+        @Override
+        public void setLogToLogcat(boolean logToLogcat) {
+            this.mLogToLogcat = logToLogcat;
+        }
+
+        @Override
+        public int getId() {
+            return ordinal();
+        }
+
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogViewerConfigReaderTest.java b/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogViewerConfigReaderTest.java
new file mode 100644
index 0000000..2539653
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/LegacyProtoLogViewerConfigReaderTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.protolog;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.SmallTest;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.util.zip.GZIPOutputStream;
+
+@SmallTest
+@Presubmit
+@RunWith(JUnit4.class)
+public class LegacyProtoLogViewerConfigReaderTest {
+    private static final String TEST_VIEWER_CONFIG = "{\n"
+            + "  \"version\": \"1.0.0\",\n"
+            + "  \"messages\": {\n"
+            + "    \"70933285\": {\n"
+            + "      \"message\": \"Test completed successfully: %b\",\n"
+            + "      \"level\": \"ERROR\",\n"
+            + "      \"group\": \"GENERIC_WM\"\n"
+            + "    },\n"
+            + "    \"1792430067\": {\n"
+            + "      \"message\": \"Attempted to add window to a display that does not exist: %d."
+            + "  Aborting.\",\n"
+            + "      \"level\": \"WARN\",\n"
+            + "      \"group\": \"GENERIC_WM\"\n"
+            + "    },\n"
+            + "    \"1352021864\": {\n"
+            + "      \"message\": \"Test 2\",\n"
+            + "      \"level\": \"WARN\",\n"
+            + "      \"group\": \"GENERIC_WM\"\n"
+            + "    },\n"
+            + "    \"409412266\": {\n"
+            + "      \"message\": \"Window %s is already added\",\n"
+            + "      \"level\": \"WARN\",\n"
+            + "      \"group\": \"GENERIC_WM\"\n"
+            + "    }\n"
+            + "  },\n"
+            + "  \"groups\": {\n"
+            + "    \"GENERIC_WM\": {\n"
+            + "      \"tag\": \"WindowManager\"\n"
+            + "    }\n"
+            + "  }\n"
+            + "}\n";
+
+
+    private LegacyProtoLogViewerConfigReader
+            mConfig = new LegacyProtoLogViewerConfigReader();
+    private File mTestViewerConfig;
+
+    @Before
+    public void setUp() throws IOException {
+        mTestViewerConfig = File.createTempFile("testConfig", ".json.gz");
+        OutputStreamWriter writer = new OutputStreamWriter(
+                new GZIPOutputStream(new FileOutputStream(mTestViewerConfig)));
+        writer.write(TEST_VIEWER_CONFIG);
+        writer.close();
+    }
+
+    @After
+    public void tearDown() {
+        //noinspection ResultOfMethodCallIgnored
+        mTestViewerConfig.delete();
+    }
+
+    @Test
+    public void getViewerString_notLoaded() {
+        assertNull(mConfig.getViewerString(1));
+    }
+
+    @Test
+    public void loadViewerConfig() {
+        mConfig.loadViewerConfig(msg -> {}, mTestViewerConfig.getAbsolutePath());
+        assertEquals("Test completed successfully: %b", mConfig.getViewerString(70933285));
+        assertEquals("Test 2", mConfig.getViewerString(1352021864));
+        assertEquals("Window %s is already added", mConfig.getViewerString(409412266));
+        assertNull(mConfig.getViewerString(1));
+    }
+
+    @Test
+    public void loadViewerConfig_invalidFile() {
+        mConfig.loadViewerConfig(msg -> {}, "/tmp/unknown/file/does/not/exist");
+        // No exception is thrown.
+        assertNull(mConfig.getViewerString(1));
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/PerfettoProtoLogImplTest.java b/tests/Tracing/src/com/android/internal/protolog/PerfettoProtoLogImplTest.java
new file mode 100644
index 0000000..e841d9e
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/PerfettoProtoLogImplTest.java
@@ -0,0 +1,929 @@
+/*
+ * Copyright (C) 2024 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.protolog;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import static java.io.File.createTempFile;
+
+import android.os.SystemClock;
+import android.platform.test.annotations.Presubmit;
+import android.tools.ScenarioBuilder;
+import android.tools.traces.TraceConfig;
+import android.tools.traces.TraceConfigs;
+import android.tools.traces.io.ResultReader;
+import android.tools.traces.io.ResultWriter;
+import android.tools.traces.monitors.PerfettoTraceMonitor;
+import android.tools.traces.protolog.ProtoLogTrace;
+import android.tracing.perfetto.DataSource;
+import android.util.proto.ProtoInputStream;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.internal.protolog.ProtoLogConfigurationService.ViewerConfigFileTracer;
+import com.android.internal.protolog.common.IProtoLogGroup;
+import com.android.internal.protolog.common.LogDataType;
+import com.android.internal.protolog.common.LogLevel;
+
+import com.google.common.truth.Truth;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import perfetto.protos.Protolog;
+import perfetto.protos.ProtologCommon;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@SuppressWarnings("ConstantConditions")
+@Presubmit
+@RunWith(JUnit4.class)
+public class PerfettoProtoLogImplTest {
+    private static final String TEST_PROTOLOG_DATASOURCE_NAME = "test.android.protolog";
+    private static final String MOCK_VIEWER_CONFIG_FILE = "my/mock/viewer/config/file.pb";
+    private final File mTracingDirectory = InstrumentationRegistry.getInstrumentation()
+            .getTargetContext().getFilesDir();
+
+    private final ResultWriter mWriter = new ResultWriter()
+            .forScenario(new ScenarioBuilder()
+                    .forClass(createTempFile("temp", "").getName()).build())
+            .withOutputDir(mTracingDirectory)
+            .setRunComplete();
+
+    private final TraceConfigs mTraceConfig = new TraceConfigs(
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false)
+    );
+
+    private static ProtoLogConfigurationService sProtoLogConfigurationService;
+    private static PerfettoProtoLogImpl sProtoLog;
+    private static Protolog.ProtoLogViewerConfig.Builder sViewerConfigBuilder;
+    private static Runnable sCacheUpdater;
+
+    private static ProtoLogViewerConfigReader sReader;
+
+    public PerfettoProtoLogImplTest() throws IOException {
+    }
+
+    @BeforeClass
+    public static void setUp() throws Exception {
+        sViewerConfigBuilder = Protolog.ProtoLogViewerConfig.newBuilder()
+                .addGroups(
+                        Protolog.ProtoLogViewerConfig.Group.newBuilder()
+                                .setId(1)
+                                .setName(TestProtoLogGroup.TEST_GROUP.toString())
+                                .setTag(TestProtoLogGroup.TEST_GROUP.getTag())
+                ).addMessages(
+                        Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(1)
+                                .setMessage("My Test Debug Log Message %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_DEBUG)
+                                .setGroupId(1)
+                                .setLocation("com/test/MyTestClass.java:123")
+                ).addMessages(
+                        Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(2)
+                                .setMessage("My Test Verbose Log Message %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_VERBOSE)
+                                .setGroupId(1)
+                                .setLocation("com/test/MyTestClass.java:342")
+                ).addMessages(
+                        Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(3)
+                                .setMessage("My Test Warn Log Message %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_WARN)
+                                .setGroupId(1)
+                                .setLocation("com/test/MyTestClass.java:563")
+                ).addMessages(
+                        Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(4)
+                                .setMessage("My Test Error Log Message %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_ERROR)
+                                .setGroupId(1)
+                                .setLocation("com/test/MyTestClass.java:156")
+                ).addMessages(
+                        Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(5)
+                                .setMessage("My Test WTF Log Message %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_WTF)
+                                .setGroupId(1)
+                                .setLocation("com/test/MyTestClass.java:192")
+                );
+
+        ViewerConfigInputStreamProvider viewerConfigInputStreamProvider = Mockito.mock(
+                ViewerConfigInputStreamProvider.class);
+        Mockito.when(viewerConfigInputStreamProvider.getInputStream())
+                .thenAnswer(it -> new ProtoInputStream(sViewerConfigBuilder.build().toByteArray()));
+
+        sCacheUpdater = () -> {};
+        sReader = Mockito.spy(new ProtoLogViewerConfigReader(viewerConfigInputStreamProvider));
+
+        final ProtoLogDataSourceBuilder dataSourceBuilder =
+                (onStart, onFlush, onStop) -> new ProtoLogDataSource(
+                        onStart, onFlush, onStop, TEST_PROTOLOG_DATASOURCE_NAME);
+        final ViewerConfigFileTracer tracer = (dataSource, viewerConfigFilePath) -> {
+            Utils.dumpViewerConfig(dataSource, () -> {
+                if (!viewerConfigFilePath.equals(MOCK_VIEWER_CONFIG_FILE)) {
+                    throw new RuntimeException(
+                            "Unexpected viewer config file path provided");
+                }
+                return new ProtoInputStream(sViewerConfigBuilder.build().toByteArray());
+            });
+        };
+        sProtoLogConfigurationService = new ProtoLogConfigurationService(dataSourceBuilder, tracer);
+
+        if (android.tracing.Flags.clientSideProtoLogging()) {
+            sProtoLog = new PerfettoProtoLogImpl(
+                    MOCK_VIEWER_CONFIG_FILE, sReader, () -> sCacheUpdater.run(),
+                    TestProtoLogGroup.values(), dataSourceBuilder, sProtoLogConfigurationService);
+        } else {
+            sProtoLog = new PerfettoProtoLogImpl(
+                    viewerConfigInputStreamProvider, sReader, () -> sCacheUpdater.run(),
+                    TestProtoLogGroup.values(), dataSourceBuilder, sProtoLogConfigurationService);
+        }
+    }
+
+    @Before
+    public void before() {
+        Mockito.reset(sReader);
+
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+    }
+
+    @After
+    public void tearDown() {
+        ProtoLogImpl.setSingleInstance(null);
+    }
+
+    @Test
+    public void isEnabled_returnsFalseByDefault() {
+        assertFalse(sProtoLog.isProtoEnabled());
+    }
+
+    @Test
+    public void isEnabled_returnsTrueAfterStart() {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            assertTrue(sProtoLog.isProtoEnabled());
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+    }
+
+    @Test
+    public void isEnabled_returnsFalseAfterStop() {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            assertTrue(sProtoLog.isProtoEnabled());
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        assertFalse(sProtoLog.isProtoEnabled());
+    }
+
+    @Test
+    public void defaultMode() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(false, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            // Shouldn't be logging anything except WTF unless explicitly requested in the group
+            // override.
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.VERBOSE, TestProtoLogGroup.TEST_GROUP, 2,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WARN, TestProtoLogGroup.TEST_GROUP, 3,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.ERROR, TestProtoLogGroup.TEST_GROUP, 4,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WTF, TestProtoLogGroup.TEST_GROUP, 5,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.getFirst().getLevel()).isEqualTo(LogLevel.WTF);
+    }
+
+    @Test
+    public void respectsOverrideConfigs_defaultMode() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(
+                        true,
+                        List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.DEBUG, true)),
+                        TEST_PROTOLOG_DATASOURCE_NAME
+                ).build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.VERBOSE, TestProtoLogGroup.TEST_GROUP, 2,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WARN, TestProtoLogGroup.TEST_GROUP, 3,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.ERROR, TestProtoLogGroup.TEST_GROUP, 4,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WTF, TestProtoLogGroup.TEST_GROUP, 5,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(5);
+        Truth.assertThat(protolog.messages.get(0).getLevel()).isEqualTo(LogLevel.DEBUG);
+        Truth.assertThat(protolog.messages.get(1).getLevel()).isEqualTo(LogLevel.VERBOSE);
+        Truth.assertThat(protolog.messages.get(2).getLevel()).isEqualTo(LogLevel.WARN);
+        Truth.assertThat(protolog.messages.get(3).getLevel()).isEqualTo(LogLevel.ERROR);
+        Truth.assertThat(protolog.messages.get(4).getLevel()).isEqualTo(LogLevel.WTF);
+    }
+
+    @Test
+    public void respectsOverrideConfigs_allEnabledMode() throws IOException {
+        PerfettoTraceMonitor traceMonitor =
+                PerfettoTraceMonitor.newBuilder().enableProtoLog(
+                        true,
+                        List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.WARN, false)),
+                        TEST_PROTOLOG_DATASOURCE_NAME
+                    ).build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.VERBOSE, TestProtoLogGroup.TEST_GROUP, 2,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WARN, TestProtoLogGroup.TEST_GROUP, 3,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.ERROR, TestProtoLogGroup.TEST_GROUP, 4,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WTF, TestProtoLogGroup.TEST_GROUP, 5,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(3);
+        Truth.assertThat(protolog.messages.get(0).getLevel()).isEqualTo(LogLevel.WARN);
+        Truth.assertThat(protolog.messages.get(1).getLevel()).isEqualTo(LogLevel.ERROR);
+        Truth.assertThat(protolog.messages.get(2).getLevel()).isEqualTo(LogLevel.WTF);
+    }
+
+    @Test
+    public void respectsAllEnabledMode() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.VERBOSE, TestProtoLogGroup.TEST_GROUP, 2,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WARN, TestProtoLogGroup.TEST_GROUP, 3,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.ERROR, TestProtoLogGroup.TEST_GROUP, 4,
+                    LogDataType.BOOLEAN, new Object[]{true});
+            sProtoLog.log(LogLevel.WTF, TestProtoLogGroup.TEST_GROUP, 5,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(5);
+        Truth.assertThat(protolog.messages.get(0).getLevel()).isEqualTo(LogLevel.DEBUG);
+        Truth.assertThat(protolog.messages.get(1).getLevel()).isEqualTo(LogLevel.VERBOSE);
+        Truth.assertThat(protolog.messages.get(2).getLevel()).isEqualTo(LogLevel.WARN);
+        Truth.assertThat(protolog.messages.get(3).getLevel()).isEqualTo(LogLevel.ERROR);
+        Truth.assertThat(protolog.messages.get(4).getLevel()).isEqualTo(LogLevel.WTF);
+    }
+
+    @Test
+    public void log_logcatEnabled() {
+        when(sReader.getViewerString(anyLong())).thenReturn("test %b %d %% 0x%x %s %f");
+        PerfettoProtoLogImpl implSpy = Mockito.spy(sProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{true, 10000, 30000, "test", 0.000003});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO),
+                eq("test true 10000 % 0x7530 test 3.0E-6"));
+        verify(sReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatEnabledInvalidMessage() {
+        when(sReader.getViewerString(anyLong())).thenReturn("test %b %d %% %x %s %f");
+        PerfettoProtoLogImpl implSpy = Mockito.spy(sProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{true, 10000, 0.0001, 0.00002, "test"});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO),
+                eq("FORMAT_ERROR \"test %b %d %% %x %s %f\", "
+                        + "args=(true, 10000, 1.0E-4, 2.0E-5, test)"));
+        verify(sReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatEnabledNoMessage() {
+        when(sReader.getViewerString(anyLong())).thenReturn(null);
+        PerfettoProtoLogImpl implSpy = Mockito.spy(sProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(true);
+        TestProtoLogGroup.TEST_GROUP.setLogToProto(false);
+
+        implSpy.log(LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{5});
+
+        verify(implSpy).passToLogcat(eq(TestProtoLogGroup.TEST_GROUP.getTag()), eq(
+                LogLevel.INFO), eq("UNKNOWN MESSAGE args = (5)"));
+        verify(sReader).getViewerString(eq(1234L));
+    }
+
+    @Test
+    public void log_logcatDisabled() {
+        when(sReader.getViewerString(anyLong())).thenReturn("test %d");
+        PerfettoProtoLogImpl implSpy = Mockito.spy(sProtoLog);
+        TestProtoLogGroup.TEST_GROUP.setLogToLogcat(false);
+
+        implSpy.log(
+                LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, 1234, 4321,
+                new Object[]{5});
+
+        verify(implSpy, never()).passToLogcat(any(), any(), any());
+        verify(sReader, never()).getViewerString(anyLong());
+    }
+
+    @Test
+    public void log_protoEnabled() throws Exception {
+        final long messageHash = addMessageToConfig(
+                ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_INFO,
+                "My test message :: %s, %d, %o, %x, %f, %e, %g, %b");
+
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        long before;
+        long after;
+        try {
+            assertFalse(sProtoLog.isProtoEnabled());
+            traceMonitor.start();
+            assertTrue(sProtoLog.isProtoEnabled());
+
+            before = SystemClock.elapsedRealtimeNanos();
+            sProtoLog.log(
+                    LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, messageHash,
+                    0b1110101001010100,
+                    new Object[]{"test", 1, 2, 3, 0.4, 0.5, 0.6, true});
+            after = SystemClock.elapsedRealtimeNanos();
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.getFirst().getTimestamp().getElapsedNanos())
+                .isAtLeast(before);
+        Truth.assertThat(protolog.messages.getFirst().getTimestamp().getElapsedNanos())
+                .isAtMost(after);
+        Truth.assertThat(protolog.messages.getFirst().getMessage())
+                .isEqualTo(
+                        "My test message :: test, 1, 2, 3, 0.400000, 5.000000e-01, 0.6, true");
+    }
+
+    @Test
+    public void log_noProcessing() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        long before;
+        long after;
+        try {
+            traceMonitor.start();
+            assertTrue(sProtoLog.isProtoEnabled());
+
+            before = SystemClock.elapsedRealtimeNanos();
+            sProtoLog.log(
+                    LogLevel.INFO, TestProtoLogGroup.TEST_GROUP,
+                    "My test message :: %s, %d, %x, %f, %b",
+                    "test", 1, 3, 0.4, true);
+            after = SystemClock.elapsedRealtimeNanos();
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.getFirst().getTimestamp().getElapsedNanos())
+                .isAtLeast(before);
+        Truth.assertThat(protolog.messages.getFirst().getTimestamp().getElapsedNanos())
+                .isAtMost(after);
+        Truth.assertThat(protolog.messages.getFirst().getMessage())
+                .isEqualTo("My test message :: test, 1, 3, 0.400000, true");
+    }
+
+    @Test
+    public  void supportsLocationInformation() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.get(0).getLocation())
+                .isEqualTo("com/test/MyTestClass.java:123");
+    }
+
+    private long addMessageToConfig(ProtologCommon.ProtoLogLevel logLevel, String message) {
+        final long messageId = new Random().nextLong();
+        sViewerConfigBuilder.addMessages(Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                .setMessageId(messageId)
+                .setMessage(message)
+                .setLevel(logLevel)
+                .setGroupId(1)
+        );
+
+        return messageId;
+    }
+
+    @Test
+    public void log_invalidParamsMask() {
+        final long messageHash = addMessageToConfig(
+                ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_INFO,
+                "My test message :: %s, %d, %f, %b");
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        long before;
+        long after;
+        try {
+            traceMonitor.start();
+            before = SystemClock.elapsedRealtimeNanos();
+            sProtoLog.log(
+                    LogLevel.INFO, TestProtoLogGroup.TEST_GROUP, messageHash,
+                    0b01100100,
+                    new Object[]{"test", 1, 0.1, true});
+            after = SystemClock.elapsedRealtimeNanos();
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        assertThrows(IllegalStateException.class, reader::readProtoLogTrace);
+    }
+
+    @Test
+    public void log_protoDisabled() throws Exception {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(false, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    0b11, new Object[]{true});
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).isEmpty();
+    }
+
+    @Test
+    public void stackTraceTrimmed() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(
+                        true,
+                        List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.DEBUG,
+                                true)),
+                        TEST_PROTOLOG_DATASOURCE_NAME
+                ).build();
+        try {
+            traceMonitor.start();
+
+            ProtoLogImpl.setSingleInstance(sProtoLog);
+            ProtoLogImpl.d(TestProtoLogGroup.TEST_GROUP, 1,
+                    0b11, true);
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        String stacktrace = protolog.messages.getFirst().getStacktrace();
+        Truth.assertThat(stacktrace)
+                .doesNotContain(PerfettoProtoLogImpl.class.getSimpleName() + ".java");
+        Truth.assertThat(stacktrace).doesNotContain(DataSource.class.getSimpleName() + ".java");
+        Truth.assertThat(stacktrace)
+                .doesNotContain(ProtoLogImpl.class.getSimpleName() + ".java");
+        Truth.assertThat(stacktrace).contains(PerfettoProtoLogImplTest.class.getSimpleName());
+        Truth.assertThat(stacktrace).contains("stackTraceTrimmed");
+    }
+
+    @Test
+    public void cacheIsUpdatedWhenTracesStartAndStop() {
+        final AtomicInteger cacheUpdateCallCount = new AtomicInteger(0);
+        sCacheUpdater = cacheUpdateCallCount::incrementAndGet;
+
+        PerfettoTraceMonitor traceMonitor1 = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true,
+                        List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.WARN,
+                                false)), TEST_PROTOLOG_DATASOURCE_NAME
+                ).build();
+
+        PerfettoTraceMonitor traceMonitor2 =
+                PerfettoTraceMonitor.newBuilder().enableProtoLog(true,
+                                List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                        TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.DEBUG,
+                                        false)), TEST_PROTOLOG_DATASOURCE_NAME)
+                        .build();
+
+        Truth.assertThat(cacheUpdateCallCount.get()).isEqualTo(0);
+
+        try {
+            traceMonitor1.start();
+
+            Truth.assertThat(cacheUpdateCallCount.get()).isEqualTo(1);
+
+            try {
+                traceMonitor2.start();
+
+                Truth.assertThat(cacheUpdateCallCount.get()).isEqualTo(2);
+            } finally {
+                traceMonitor2.stop(mWriter);
+            }
+
+            Truth.assertThat(cacheUpdateCallCount.get()).isEqualTo(3);
+
+        } finally {
+            traceMonitor1.stop(mWriter);
+        }
+
+        Truth.assertThat(cacheUpdateCallCount.get()).isEqualTo(4);
+    }
+
+    @Test
+    public void isEnabledUpdatesBasedOnRunningTraces() {
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.DEBUG))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.VERBOSE))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.INFO))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WARN))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.ERROR))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WTF)).isFalse();
+
+        PerfettoTraceMonitor traceMonitor1 =
+                PerfettoTraceMonitor.newBuilder().enableProtoLog(true,
+                                List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                        TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.WARN,
+                                        false)), TEST_PROTOLOG_DATASOURCE_NAME)
+                        .build();
+
+        PerfettoTraceMonitor traceMonitor2 =
+                PerfettoTraceMonitor.newBuilder().enableProtoLog(true,
+                                List.of(new PerfettoTraceMonitor.Builder.ProtoLogGroupOverride(
+                                        TestProtoLogGroup.TEST_GROUP.toString(), LogLevel.DEBUG,
+                                        false)), TEST_PROTOLOG_DATASOURCE_NAME)
+                        .build();
+
+        try {
+            traceMonitor1.start();
+
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.DEBUG))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.VERBOSE))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.INFO))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WARN))
+                    .isTrue();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.ERROR))
+                    .isTrue();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WTF))
+                    .isTrue();
+
+            try {
+                traceMonitor2.start();
+
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.DEBUG))
+                        .isTrue();
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP,
+                        LogLevel.VERBOSE)).isTrue();
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.INFO))
+                        .isTrue();
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WARN))
+                        .isTrue();
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.ERROR))
+                        .isTrue();
+                Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WTF))
+                        .isTrue();
+            } finally {
+                traceMonitor2.stop(mWriter);
+            }
+
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.DEBUG))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.VERBOSE))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.INFO))
+                    .isFalse();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WARN))
+                    .isTrue();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.ERROR))
+                    .isTrue();
+            Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WTF))
+                    .isTrue();
+        } finally {
+            traceMonitor1.stop(mWriter);
+        }
+
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.DEBUG))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.VERBOSE))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.INFO))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WARN))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.ERROR))
+                .isFalse();
+        Truth.assertThat(sProtoLog.isEnabled(TestProtoLogGroup.TEST_GROUP, LogLevel.WTF))
+                .isFalse();
+    }
+
+    @Test
+    public void supportsNullString() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+
+        try {
+            traceMonitor.start();
+
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP,
+                    "My test null string: %s", (Object) null);
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.get(0).getMessage())
+                .isEqualTo("My test null string: null");
+    }
+
+    @Test
+    public void supportNullParams() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+
+        try {
+            traceMonitor.start();
+
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP,
+                    "My null args: %d, %f, %b", null, null, null);
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(1);
+        Truth.assertThat(protolog.messages.get(0).getMessage())
+                .isEqualTo("My null args: 0, 0.000000, false");
+    }
+
+    @Test
+    public void handlesConcurrentTracingSessions() throws IOException {
+        PerfettoTraceMonitor traceMonitor1 = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+
+        PerfettoTraceMonitor traceMonitor2 = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(true, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+
+        final ResultWriter writer2 = new ResultWriter()
+                .forScenario(new ScenarioBuilder()
+                        .forClass(createTempFile("temp", "").getName()).build())
+                .withOutputDir(mTracingDirectory)
+                .setRunComplete();
+
+        try {
+            traceMonitor1.start();
+            traceMonitor2.start();
+
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP, 1,
+                    LogDataType.BOOLEAN, new Object[]{true});
+        } finally {
+            traceMonitor1.stop(mWriter);
+            traceMonitor2.stop(writer2);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protologFromMonitor1 = reader.readProtoLogTrace();
+
+        final ResultReader reader2 = new ResultReader(writer2.write(), mTraceConfig);
+        final ProtoLogTrace protologFromMonitor2 = reader2.readProtoLogTrace();
+
+        Truth.assertThat(protologFromMonitor1.messages).hasSize(1);
+        Truth.assertThat(protologFromMonitor1.messages.get(0).getMessage())
+                .isEqualTo("My Test Debug Log Message true");
+
+        Truth.assertThat(protologFromMonitor2.messages).hasSize(1);
+        Truth.assertThat(protologFromMonitor2.messages.get(0).getMessage())
+                .isEqualTo("My Test Debug Log Message true");
+    }
+
+    @Test
+    public void usesDefaultLogFromLevel() throws IOException {
+        PerfettoTraceMonitor traceMonitor = PerfettoTraceMonitor.newBuilder()
+                .enableProtoLog(LogLevel.WARN, List.of(), TEST_PROTOLOG_DATASOURCE_NAME)
+                .build();
+        try {
+            traceMonitor.start();
+            sProtoLog.log(LogLevel.DEBUG, TestProtoLogGroup.TEST_GROUP,
+                    "This message should not be logged");
+            sProtoLog.log(LogLevel.WARN, TestProtoLogGroup.TEST_GROUP,
+                    "This message should be logged %d", 123);
+            sProtoLog.log(LogLevel.ERROR, TestProtoLogGroup.TEST_GROUP,
+                    "This message should also be logged %d", 567);
+        } finally {
+            traceMonitor.stop(mWriter);
+        }
+
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final ProtoLogTrace protolog = reader.readProtoLogTrace();
+
+        Truth.assertThat(protolog.messages).hasSize(2);
+
+        Truth.assertThat(protolog.messages.get(0).getLevel())
+                .isEqualTo(LogLevel.WARN);
+        Truth.assertThat(protolog.messages.get(0).getMessage())
+                .isEqualTo("This message should be logged 123");
+
+        Truth.assertThat(protolog.messages.get(1).getLevel())
+                .isEqualTo(LogLevel.ERROR);
+        Truth.assertThat(protolog.messages.get(1).getMessage())
+                .isEqualTo("This message should also be logged 567");
+    }
+
+    private enum TestProtoLogGroup implements IProtoLogGroup {
+        TEST_GROUP(true, true, false, "TEST_TAG");
+
+        private final boolean mEnabled;
+        private volatile boolean mLogToProto;
+        private volatile boolean mLogToLogcat;
+        private final String mTag;
+
+        /**
+         * @param enabled     set to false to exclude all log statements for this group from
+         *                    compilation,
+         *                    they will not be available in runtime.
+         * @param logToProto  enable binary logging for the group
+         * @param logToLogcat enable text logging for the group
+         * @param tag         name of the source of the logged message
+         */
+        TestProtoLogGroup(boolean enabled, boolean logToProto, boolean logToLogcat, String tag) {
+            this.mEnabled = enabled;
+            this.mLogToProto = logToProto;
+            this.mLogToLogcat = logToLogcat;
+            this.mTag = tag;
+        }
+
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        @Override
+        public boolean isLogToProto() {
+            return mLogToProto;
+        }
+
+        @Override
+        public boolean isLogToLogcat() {
+            return mLogToLogcat;
+        }
+
+        @Override
+        public boolean isLogToAny() {
+            return mLogToLogcat || mLogToProto;
+        }
+
+        @Override
+        public String getTag() {
+            return mTag;
+        }
+
+        @Override
+        public void setLogToProto(boolean logToProto) {
+            this.mLogToProto = logToProto;
+        }
+
+        @Override
+        public void setLogToLogcat(boolean logToLogcat) {
+            this.mLogToLogcat = logToLogcat;
+        }
+
+        @Override
+        public int getId() {
+            return ordinal();
+        }
+
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java
new file mode 100644
index 0000000..be0c7da
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2024 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.protolog;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.times;
+
+import android.os.Binder;
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner.class)
+public class ProtoLogCommandHandlerTest {
+
+    @Mock
+    ProtoLogConfigurationService mProtoLogConfigurationService;
+    @Mock
+    PrintWriter mPrintWriter;
+    @Mock
+    Binder mMockBinder;
+
+    @Test
+    public void printsHelpForAllAvailableCommands() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.onHelp();
+        validateOnHelpPrinted();
+    }
+
+    @Test
+    public void printsHelpIfCommandIsNull() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.onCommand(null);
+        validateOnHelpPrinted();
+    }
+
+    @Test
+    public void handlesGroupListCommand() {
+        Mockito.when(mProtoLogConfigurationService.getGroups())
+                .thenReturn(new String[] {"MY_TEST_GROUP", "MY_OTHER_GROUP"});
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "groups", "list" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_TEST_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_OTHER_GROUP"));
+    }
+
+    @Test
+    public void handlesIncompleteGroupsCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "groups" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommand() {
+        Mockito.when(mProtoLogConfigurationService.getGroups())
+                .thenReturn(new String[] {"MY_GROUP"});
+        Mockito.when(mProtoLogConfigurationService.isLoggingToLogcat("MY_GROUP")).thenReturn(true);
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "groups", "status", "MY_GROUP" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("LOG_TO_LOGCAT = true"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommandOfUnregisteredGroups() {
+        Mockito.when(mProtoLogConfigurationService.getGroups()).thenReturn(new String[] {});
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "groups", "status", "MY_GROUP" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("UNREGISTERED"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "groups", "status" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesIncompleteLogcatCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "logcat" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesLogcatEnableCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "logcat", "enable", "MY_GROUP" });
+        Mockito.verify(mProtoLogConfigurationService).enableProtoLogToLogcat("MY_GROUP");
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err,
+                new String[] { "logcat", "enable", "MY_GROUP", "MY_OTHER_GROUP" });
+        Mockito.verify(mProtoLogConfigurationService)
+                .enableProtoLogToLogcat("MY_GROUP", "MY_OTHER_GROUP");
+    }
+
+    @Test
+    public void handlesLogcatDisableCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "logcat", "disable", "MY_GROUP" });
+        Mockito.verify(mProtoLogConfigurationService).disableProtoLogToLogcat("MY_GROUP");
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err,
+                new String[] { "logcat", "disable", "MY_GROUP", "MY_OTHER_GROUP" });
+        Mockito.verify(mProtoLogConfigurationService)
+                .disableProtoLogToLogcat("MY_GROUP", "MY_OTHER_GROUP");
+    }
+
+    @Test
+    public void handlesLogcatEnableCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "logcat", "enable" });
+        Mockito.verify(mPrintWriter).println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesLogcatDisableCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogConfigurationService, mPrintWriter);
+
+        cmdHandler.exec(mMockBinder, FileDescriptor.in, FileDescriptor.out,
+                FileDescriptor.err, new String[] { "logcat", "disable" });
+        Mockito.verify(mPrintWriter).println(contains("Incomplete command"));
+    }
+
+    private void validateOnHelpPrinted() {
+        Mockito.verify(mPrintWriter, times(1)).println(endsWith("help"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(endsWith("groups (list | status)"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(endsWith("logcat (enable | disable) <group>"));
+        Mockito.verify(mPrintWriter, atLeast(0)).println(anyString());
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogConfigurationServiceTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogConfigurationServiceTest.java
new file mode 100644
index 0000000..e1bdd77
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogConfigurationServiceTest.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright (C) 2024 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.protolog;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+
+import static java.io.File.createTempFile;
+import static java.nio.file.Files.createTempDirectory;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.platform.test.annotations.Presubmit;
+import android.tools.ScenarioBuilder;
+import android.tools.Tag;
+import android.tools.io.ResultArtifactDescriptor;
+import android.tools.io.TraceType;
+import android.tools.traces.TraceConfig;
+import android.tools.traces.TraceConfigs;
+import android.tools.traces.io.ResultReader;
+import android.tools.traces.io.ResultWriter;
+import android.tools.traces.monitors.PerfettoTraceMonitor;
+
+import com.google.common.truth.Truth;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import perfetto.protos.Protolog.ProtoLogViewerConfig;
+import perfetto.protos.ProtologCommon;
+import perfetto.protos.TraceOuterClass.Trace;
+import perfetto.protos.TracePacketOuterClass.TracePacket;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner.class)
+public class ProtoLogConfigurationServiceTest {
+
+    private static final String TEST_GROUP = "MY_TEST_GROUP";
+    private static final String OTHER_TEST_GROUP = "MY_OTHER_TEST_GROUP";
+
+    private static final ProtoLogViewerConfig VIEWER_CONFIG =
+            ProtoLogViewerConfig.newBuilder()
+                    .addGroups(
+                            ProtoLogViewerConfig.Group.newBuilder()
+                                    .setId(1)
+                                    .setName(TEST_GROUP)
+                                    .setTag(TEST_GROUP)
+                    ).addMessages(
+                            ProtoLogViewerConfig.MessageData.newBuilder()
+                                    .setMessageId(1)
+                                    .setMessage("My Test Debug Log Message %b")
+                                    .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_DEBUG)
+                                    .setGroupId(1)
+                    ).addMessages(
+                            ProtoLogViewerConfig.MessageData.newBuilder()
+                                    .setMessageId(2)
+                                    .setMessage("My Test Verbose Log Message %b")
+                                    .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_VERBOSE)
+                                    .setGroupId(1)
+                    ).build();
+
+    @Mock
+    IProtoLogClient mMockClient;
+
+    @Mock
+    IProtoLogClient mSecondMockClient;
+
+    @Mock
+    IBinder mMockClientBinder;
+
+    @Mock
+    IBinder mSecondMockClientBinder;
+
+    private final File mTracingDirectory = createTempDirectory("temp").toFile();
+
+    private final ResultWriter mWriter = new ResultWriter()
+            .forScenario(new ScenarioBuilder()
+                    .forClass(createTempFile("temp", "").getName()).build())
+            .withOutputDir(mTracingDirectory)
+            .setRunComplete();
+
+    private final TraceConfigs mTraceConfig = new TraceConfigs(
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false),
+            new TraceConfig(false, true, false)
+    );
+
+    @Captor
+    ArgumentCaptor<IBinder.DeathRecipient> mDeathRecipientArgumentCaptor;
+
+    @Captor
+    ArgumentCaptor<IBinder.DeathRecipient> mSecondDeathRecipientArgumentCaptor;
+
+    private File mViewerConfigFile;
+
+    public ProtoLogConfigurationServiceTest() throws IOException {
+    }
+
+    @Before
+    public void setUp() {
+        Mockito.when(mMockClient.asBinder()).thenReturn(mMockClientBinder);
+        Mockito.when(mSecondMockClient.asBinder()).thenReturn(mSecondMockClientBinder);
+
+        try {
+            mViewerConfigFile = File.createTempFile("viewer-config", ".pb");
+            try (var fos = new FileOutputStream(mViewerConfigFile);
+                    BufferedOutputStream bos = new BufferedOutputStream(fos)) {
+
+                bos.write(VIEWER_CONFIG.toByteArray());
+            }
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void canRegisterClientWithGroupsOnly() throws RemoteException {
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService();
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, true));
+        service.registerClient(mMockClient, args);
+
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isTrue();
+        Truth.assertThat(service.getGroups()).asList().containsExactly(TEST_GROUP);
+    }
+
+    @Test
+    public void willDumpViewerConfigOnlyOnceOnTraceStop()
+            throws RemoteException, InvalidProtocolBufferException {
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService();
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, true))
+                        .setViewerConfigFile(mViewerConfigFile.getAbsolutePath());
+        service.registerClient(mMockClient, args);
+        service.registerClient(mSecondMockClient, args);
+
+        PerfettoTraceMonitor traceMonitor =
+                PerfettoTraceMonitor.newBuilder().enableProtoLog().build();
+
+        traceMonitor.start();
+        traceMonitor.stop(mWriter);
+        final ResultReader reader = new ResultReader(mWriter.write(), mTraceConfig);
+        final byte[] traceData = reader.getArtifact()
+                .readBytes(new ResultArtifactDescriptor(TraceType.PERFETTO, Tag.ALL));
+
+        final Trace trace = Trace.parseFrom(traceData);
+
+        final List<TracePacket> configPackets = trace.getPacketList().stream()
+                .filter(it -> it.hasProtologViewerConfig())
+                // Exclude viewer configs from regular system tracing
+                .filter(it ->
+                        it.getProtologViewerConfig().getGroups(0).getName().equals(TEST_GROUP))
+                .toList();
+        Truth.assertThat(configPackets).hasSize(1);
+        Truth.assertThat(configPackets.get(0).getProtologViewerConfig().toString())
+                .isEqualTo(VIEWER_CONFIG.toString());
+    }
+
+    @Test
+    public void willDumpViewerConfigOnLastClientDisconnected()
+            throws RemoteException, FileNotFoundException {
+        final ProtoLogConfigurationService.ViewerConfigFileTracer tracer =
+                Mockito.mock(ProtoLogConfigurationService.ViewerConfigFileTracer.class);
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService(tracer);
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, true))
+                        .setViewerConfigFile(mViewerConfigFile.getAbsolutePath());
+        service.registerClient(mMockClient, args);
+        service.registerClient(mSecondMockClient, args);
+
+        Mockito.verify(mMockClientBinder)
+                .linkToDeath(mDeathRecipientArgumentCaptor.capture(), anyInt());
+        Mockito.verify(mSecondMockClientBinder)
+                .linkToDeath(mSecondDeathRecipientArgumentCaptor.capture(), anyInt());
+
+        mDeathRecipientArgumentCaptor.getValue().binderDied();
+        Mockito.verify(tracer, never()).trace(any(), any());
+        mSecondDeathRecipientArgumentCaptor.getValue().binderDied();
+        Mockito.verify(tracer).trace(any(), eq(mViewerConfigFile.getAbsolutePath()));
+    }
+
+    @Test
+    public void sendEnableLoggingToLogcatToClient() throws RemoteException {
+        final var service = new ProtoLogConfigurationService();
+
+        final var args = new ProtoLogConfigurationService.RegisterClientArgs()
+                .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                        .GroupConfig(TEST_GROUP, false));
+        service.registerClient(mMockClient, args);
+
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isFalse();
+        service.enableProtoLogToLogcat(TEST_GROUP);
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isTrue();
+
+        Mockito.verify(mMockClient).toggleLogcat(eq(true),
+                Mockito.argThat(it -> it.length == 1 && it[0].equals(TEST_GROUP)));
+    }
+
+    @Test
+    public void sendDisableLoggingToLogcatToClient() throws RemoteException {
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService();
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, true));
+        service.registerClient(mMockClient, args);
+
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isTrue();
+        service.disableProtoLogToLogcat(TEST_GROUP);
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isFalse();
+
+        Mockito.verify(mMockClient).toggleLogcat(eq(false),
+                Mockito.argThat(it -> it.length == 1 && it[0].equals(TEST_GROUP)));
+    }
+
+    @Test
+    public void doNotSendLoggingToLogcatToClientWithoutRegisteredGroup() throws RemoteException {
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService();
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, false));
+        service.registerClient(mMockClient, args);
+
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isFalse();
+        service.enableProtoLogToLogcat(OTHER_TEST_GROUP);
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isFalse();
+
+        Mockito.verify(mMockClient, never()).toggleLogcat(anyBoolean(), any());
+    }
+
+    @Test
+    public void handlesToggleToLogcatBeforeClientIsRegistered() throws RemoteException {
+        final ProtoLogConfigurationService service = new ProtoLogConfigurationService();
+
+        Truth.assertThat(service.getGroups()).asList().doesNotContain(TEST_GROUP);
+        service.enableProtoLogToLogcat(TEST_GROUP);
+        Truth.assertThat(service.isLoggingToLogcat(TEST_GROUP)).isTrue();
+
+        final ProtoLogConfigurationService.RegisterClientArgs args =
+                new ProtoLogConfigurationService.RegisterClientArgs()
+                        .setGroups(new ProtoLogConfigurationService.RegisterClientArgs
+                                .GroupConfig(TEST_GROUP, false));
+        service.registerClient(mMockClient, args);
+
+        Mockito.verify(mMockClient).toggleLogcat(eq(true),
+                Mockito.argThat(it -> it.length == 1 && it[0].equals(TEST_GROUP)));
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogImplTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogImplTest.java
new file mode 100644
index 0000000..0496240
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogImplTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.protolog;
+
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.protolog.common.IProtoLog;
+import com.android.internal.protolog.common.IProtoLogGroup;
+import com.android.internal.protolog.common.LogLevel;
+
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@SuppressWarnings("ConstantConditions")
+@SmallTest
+@Presubmit
+@RunWith(JUnit4.class)
+public class ProtoLogImplTest {
+    @After
+    public void tearDown() {
+        ProtoLogImpl.setSingleInstance(null);
+    }
+
+    @Test
+    public void getSingleInstance() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        assertSame(mockedProtoLog, ProtoLogImpl.getSingleInstance());
+    }
+
+    @Test
+    public void d_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.d(TestProtoLogGroup.TEST_GROUP, 1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.DEBUG), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    @Test
+    public void v_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.v(TestProtoLogGroup.TEST_GROUP, 1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.VERBOSE), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    @Test
+    public void i_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.i(TestProtoLogGroup.TEST_GROUP, 1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.INFO), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    @Test
+    public void w_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.w(TestProtoLogGroup.TEST_GROUP, 1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.WARN), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    @Test
+    public void e_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.e(TestProtoLogGroup.TEST_GROUP, 1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.ERROR), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    @Test
+    public void wtf_logCalled() {
+        IProtoLog mockedProtoLog = mock(IProtoLog.class);
+        ProtoLogImpl.setSingleInstance(mockedProtoLog);
+        ProtoLogImpl.wtf(TestProtoLogGroup.TEST_GROUP,
+                1234, 4321);
+        verify(mockedProtoLog).log(eq(LogLevel.WTF), eq(
+                TestProtoLogGroup.TEST_GROUP),
+                eq(1234L), eq(4321), eq(new Object[]{}));
+    }
+
+    private enum TestProtoLogGroup implements IProtoLogGroup {
+        TEST_GROUP(true, true, false, "WindowManagetProtoLogTest");
+
+        private final boolean mEnabled;
+        private volatile boolean mLogToProto;
+        private volatile boolean mLogToLogcat;
+        private final String mTag;
+
+        /**
+         * @param enabled     set to false to exclude all log statements for this group from
+         *                    compilation,
+         *                    they will not be available in runtime.
+         * @param logToProto  enable binary logging for the group
+         * @param logToLogcat enable text logging for the group
+         * @param tag         name of the source of the logged message
+         */
+        TestProtoLogGroup(boolean enabled, boolean logToProto, boolean logToLogcat, String tag) {
+            this.mEnabled = enabled;
+            this.mLogToProto = logToProto;
+            this.mLogToLogcat = logToLogcat;
+            this.mTag = tag;
+        }
+
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        @Override
+        public boolean isLogToProto() {
+            return mLogToProto;
+        }
+
+        @Override
+        public boolean isLogToLogcat() {
+            return mLogToLogcat;
+        }
+
+        @Override
+        public boolean isLogToAny() {
+            return mLogToLogcat || mLogToProto;
+        }
+
+        @Override
+        public String getTag() {
+            return mTag;
+        }
+
+        @Override
+        public void setLogToProto(boolean logToProto) {
+            this.mLogToProto = logToProto;
+        }
+
+        @Override
+        public void setLogToLogcat(boolean logToLogcat) {
+            this.mLogToLogcat = logToLogcat;
+        }
+
+        @Override
+        public int getId() {
+            return ordinal();
+        }
+
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java
new file mode 100644
index 0000000..9d56a92
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2024 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.protolog;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.internal.protolog.common.IProtoLogGroup;
+
+import com.google.common.truth.Truth;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test class for {@link ProtoLog}. */
+@SuppressWarnings("ConstantConditions")
+@Presubmit
+@RunWith(JUnit4.class)
+public class ProtoLogTest {
+
+    @Test
+    public void canRunProtoLogInitMultipleTimes() {
+        ProtoLog.init(TEST_GROUP_1);
+        ProtoLog.init(TEST_GROUP_1);
+        ProtoLog.init(TEST_GROUP_2);
+        ProtoLog.init(TEST_GROUP_1, TEST_GROUP_2);
+
+        final var instance = ProtoLog.getSingleInstance();
+        Truth.assertThat(instance.getRegisteredGroups())
+                .containsExactly(TEST_GROUP_1, TEST_GROUP_2);
+    }
+
+    private static final IProtoLogGroup TEST_GROUP_1 = new ProtoLogGroup("TEST_TAG_1", 1);
+    private static final IProtoLogGroup TEST_GROUP_2 = new ProtoLogGroup("TEST_TAG_2", 2);
+
+    private static class ProtoLogGroup implements IProtoLogGroup {
+        private final boolean mEnabled;
+        private volatile boolean mLogToProto;
+        private volatile boolean mLogToLogcat;
+        private final String mTag;
+        private final int mId;
+
+        ProtoLogGroup(String tag, int id) {
+            this(true, true, false, tag, id);
+        }
+
+        ProtoLogGroup(
+                boolean enabled, boolean logToProto, boolean logToLogcat, String tag, int id) {
+            this.mEnabled = enabled;
+            this.mLogToProto = logToProto;
+            this.mLogToLogcat = logToLogcat;
+            this.mTag = tag;
+            this.mId = id;
+        }
+
+        @Override
+        public String name() {
+            return mTag;
+        }
+
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        @Override
+        public boolean isLogToProto() {
+            return mLogToProto;
+        }
+
+        @Override
+        public boolean isLogToLogcat() {
+            return mLogToLogcat;
+        }
+
+        @Override
+        public boolean isLogToAny() {
+            return mLogToLogcat || mLogToProto;
+        }
+
+        @Override
+        public String getTag() {
+            return mTag;
+        }
+
+        @Override
+        public void setLogToProto(boolean logToProto) {
+            this.mLogToProto = logToProto;
+        }
+
+        @Override
+        public void setLogToLogcat(boolean logToLogcat) {
+            this.mLogToLogcat = logToLogcat;
+        }
+
+        @Override
+        public int getId() {
+            return mId;
+        }
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogViewerConfigReaderTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogViewerConfigReaderTest.java
new file mode 100644
index 0000000..28d7b42
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogViewerConfigReaderTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.protolog;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.platform.test.annotations.Presubmit;
+import android.util.proto.ProtoInputStream;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import perfetto.protos.ProtologCommon;
+
+@Presubmit
+@RunWith(JUnit4.class)
+public class ProtoLogViewerConfigReaderTest {
+    private static final String TEST_GROUP_NAME = "MY_TEST_GROUP";
+    private static final String TEST_GROUP_TAG = "TEST";
+
+    private static final String OTHER_TEST_GROUP_NAME = "MY_OTHER_TEST_GROUP";
+    private static final String OTHER_TEST_GROUP_TAG = "OTHER_TEST";
+
+    private static final byte[] TEST_VIEWER_CONFIG =
+            perfetto.protos.Protolog.ProtoLogViewerConfig.newBuilder()
+                .addGroups(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.Group.newBuilder()
+                                .setId(1)
+                                .setName(TEST_GROUP_NAME)
+                                .setTag(TEST_GROUP_TAG)
+                ).addGroups(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.Group.newBuilder()
+                                .setId(2)
+                                .setName(OTHER_TEST_GROUP_NAME)
+                                .setTag(OTHER_TEST_GROUP_TAG)
+                ).addMessages(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(1)
+                                .setMessage("My Test Log Message 1 %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_DEBUG)
+                                .setGroupId(1)
+                ).addMessages(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(2)
+                                .setMessage("My Test Log Message 2 %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_VERBOSE)
+                                .setGroupId(1)
+                ).addMessages(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(3)
+                                .setMessage("My Test Log Message 3 %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_WARN)
+                                .setGroupId(1)
+                ).addMessages(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(4)
+                                .setMessage("My Test Log Message 4 %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_ERROR)
+                                .setGroupId(2)
+                ).addMessages(
+                        perfetto.protos.Protolog.ProtoLogViewerConfig.MessageData.newBuilder()
+                                .setMessageId(5)
+                                .setMessage("My Test Log Message 5 %b")
+                                .setLevel(ProtologCommon.ProtoLogLevel.PROTOLOG_LEVEL_WTF)
+                                .setGroupId(2)
+                ).build().toByteArray();
+
+    private final ViewerConfigInputStreamProvider mViewerConfigInputStreamProvider =
+            () -> new ProtoInputStream(TEST_VIEWER_CONFIG);
+
+    private ProtoLogViewerConfigReader mConfig;
+
+    @Before
+    public void before() {
+        mConfig = new ProtoLogViewerConfigReader(mViewerConfigInputStreamProvider);
+    }
+
+    @Test
+    public void getViewerString_notLoaded() {
+        assertNull(mConfig.getViewerString(1));
+    }
+
+    @Test
+    public void loadViewerConfig() {
+        mConfig.loadViewerConfig(new String[] { TEST_GROUP_NAME });
+        assertEquals("My Test Log Message 1 %b", mConfig.getViewerString(1));
+        assertEquals("My Test Log Message 2 %b", mConfig.getViewerString(2));
+        assertEquals("My Test Log Message 3 %b", mConfig.getViewerString(3));
+        assertNull(mConfig.getViewerString(4));
+        assertNull(mConfig.getViewerString(5));
+    }
+
+    @Test
+    public void unloadViewerConfig() {
+        mConfig.loadViewerConfig(new String[] { TEST_GROUP_NAME, OTHER_TEST_GROUP_NAME });
+        mConfig.unloadViewerConfig(new String[] { TEST_GROUP_NAME });
+        assertNull(mConfig.getViewerString(1));
+        assertNull(mConfig.getViewerString(2));
+        assertNull(mConfig.getViewerString(3));
+        assertEquals("My Test Log Message 4 %b", mConfig.getViewerString(4));
+        assertEquals("My Test Log Message 5 %b", mConfig.getViewerString(5));
+
+        mConfig.unloadViewerConfig(new String[] { OTHER_TEST_GROUP_NAME });
+        assertNull(mConfig.getViewerString(4));
+        assertNull(mConfig.getViewerString(5));
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtologDataSourceTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtologDataSourceTest.java
new file mode 100644
index 0000000..ce519b7a
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtologDataSourceTest.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2024 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.protolog;
+
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
+
+import android.tracing.perfetto.CreateTlsStateArgs;
+import android.util.proto.ProtoInputStream;
+
+import com.android.internal.protolog.common.LogLevel;
+
+import com.google.common.truth.Truth;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import perfetto.protos.DataSourceConfigOuterClass;
+import perfetto.protos.ProtologCommon;
+import perfetto.protos.ProtologConfig;
+
+public class ProtologDataSourceTest {
+    @Before
+    public void before() {
+        assumeTrue(android.tracing.Flags.perfettoProtologTracing());
+    }
+
+    @Test
+    public void noConfig() {
+        final ProtoLogDataSource.TlsState tlsState = createTlsState(
+                DataSourceConfigOuterClass.DataSourceConfig.newBuilder().build());
+
+        Truth.assertThat(tlsState.getLogFromLevel("SOME_TAG")).isEqualTo(LogLevel.WTF);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_TAG")).isFalse();
+    }
+
+    @Test
+    public void defaultTraceMode() {
+        final ProtoLogDataSource.TlsState tlsState = createTlsState(
+                DataSourceConfigOuterClass.DataSourceConfig.newBuilder()
+                        .setProtologConfig(
+                                ProtologConfig.ProtoLogConfig.newBuilder()
+                                        .setTracingMode(
+                                                ProtologConfig.ProtoLogConfig.TracingMode
+                                                        .ENABLE_ALL)
+                                        .build()
+                        ).build());
+
+        Truth.assertThat(tlsState.getLogFromLevel("SOME_TAG")).isEqualTo(LogLevel.DEBUG);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_TAG")).isFalse();
+    }
+
+    @Test
+    public void allEnabledTraceMode() {
+        final ProtoLogDataSource ds =
+                new ProtoLogDataSource((idx, c) -> {}, () -> {}, (idx, c) -> {});
+
+        final ProtoLogDataSource.TlsState tlsState = createTlsState(
+                DataSourceConfigOuterClass.DataSourceConfig.newBuilder().setProtologConfig(
+                        ProtologConfig.ProtoLogConfig.newBuilder()
+                                .setTracingMode(
+                                        ProtologConfig.ProtoLogConfig.TracingMode.ENABLE_ALL)
+                                .build()
+                ).build()
+        );
+
+        Truth.assertThat(tlsState.getLogFromLevel("SOME_TAG")).isEqualTo(LogLevel.DEBUG);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_TAG")).isFalse();
+    }
+
+    @Test
+    public void requireGroupTagInOverrides() {
+        Exception exception = assertThrows(RuntimeException.class, () -> {
+            createTlsState(DataSourceConfigOuterClass.DataSourceConfig.newBuilder()
+                    .setProtologConfig(
+                            ProtologConfig.ProtoLogConfig.newBuilder()
+                                    .addGroupOverrides(
+                                            ProtologConfig.ProtoLogGroup.newBuilder()
+                                                    .setLogFrom(
+                                                            ProtologCommon.ProtoLogLevel
+                                                                    .PROTOLOG_LEVEL_WARN)
+                                                    .setCollectStacktrace(true)
+                                    )
+                                    .build()
+                    ).build());
+        });
+
+        Truth.assertThat(exception).hasMessageThat().contains("group override without a group tag");
+    }
+
+    @Test
+    public void stackTraceCollection() {
+        final ProtoLogDataSource.TlsState tlsState = createTlsState(
+                DataSourceConfigOuterClass.DataSourceConfig.newBuilder().setProtologConfig(
+                        ProtologConfig.ProtoLogConfig.newBuilder()
+                                .addGroupOverrides(
+                                        ProtologConfig.ProtoLogGroup.newBuilder()
+                                                .setGroupName("SOME_TAG")
+                                                .setCollectStacktrace(true)
+                                )
+                                .build()
+                ).build());
+
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_TAG")).isTrue();
+    }
+
+    @Test
+    public void groupLogFromOverrides() {
+        final ProtoLogDataSource.TlsState tlsState = createTlsState(
+                DataSourceConfigOuterClass.DataSourceConfig.newBuilder().setProtologConfig(
+                        ProtologConfig.ProtoLogConfig.newBuilder()
+                                .addGroupOverrides(
+                                        ProtologConfig.ProtoLogGroup.newBuilder()
+                                                .setGroupName("SOME_TAG")
+                                                .setLogFrom(
+                                                        ProtologCommon.ProtoLogLevel
+                                                                .PROTOLOG_LEVEL_DEBUG)
+                                                .setCollectStacktrace(true)
+                                )
+                                .addGroupOverrides(
+                                        ProtologConfig.ProtoLogGroup.newBuilder()
+                                                .setGroupName("SOME_OTHER_TAG")
+                                                .setLogFrom(
+                                                        ProtologCommon.ProtoLogLevel
+                                                                .PROTOLOG_LEVEL_WARN)
+                                )
+                                .build()
+                ).build());
+
+        Truth.assertThat(tlsState.getLogFromLevel("SOME_TAG")).isEqualTo(LogLevel.DEBUG);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_TAG")).isTrue();
+
+        Truth.assertThat(tlsState.getLogFromLevel("SOME_OTHER_TAG")).isEqualTo(LogLevel.WARN);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("SOME_OTHER_TAG")).isFalse();
+
+        Truth.assertThat(tlsState.getLogFromLevel("UNKNOWN_TAG")).isEqualTo(LogLevel.WTF);
+        Truth.assertThat(tlsState.getShouldCollectStacktrace("UNKNOWN_TAG")).isFalse();
+    }
+
+    private ProtoLogDataSource.TlsState createTlsState(
+            DataSourceConfigOuterClass.DataSourceConfig config) {
+        final ProtoLogDataSource ds =
+                Mockito.spy(new ProtoLogDataSource((idx, c) -> {}, () -> {}, (idx, c) -> {}));
+
+        ProtoInputStream configStream = new ProtoInputStream(config.toByteArray());
+        final ProtoLogDataSource.Instance dsInstance = Mockito.spy(
+                ds.createInstance(configStream, 8));
+        Mockito.doNothing().when(dsInstance).release();
+        final CreateTlsStateArgs mockCreateTlsStateArgs = Mockito.mock(CreateTlsStateArgs.class);
+        Mockito.when(mockCreateTlsStateArgs.getDataSourceInstanceLocked()).thenReturn(dsInstance);
+        return ds.createTlsState(mockCreateTlsStateArgs);
+    }
+}
diff --git a/tests/Tracing/src/com/android/internal/protolog/common/LogDataTypeTest.java b/tests/Tracing/src/com/android/internal/protolog/common/LogDataTypeTest.java
new file mode 100644
index 0000000..9c2f74ea
--- /dev/null
+++ b/tests/Tracing/src/com/android/internal/protolog/common/LogDataTypeTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.protolog.common;
+
+import static org.junit.Assert.assertEquals;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@SmallTest
+@Presubmit
+@RunWith(JUnit4.class)
+public class LogDataTypeTest {
+    @Test
+    public void parseFormatString() {
+        String str = "%b %d %x %f %s %%";
+        List<Integer> out = LogDataType.parseFormatString(str);
+        assertEquals(Arrays.asList(
+                LogDataType.BOOLEAN,
+                LogDataType.LONG,
+                LogDataType.LONG,
+                LogDataType.DOUBLE,
+                LogDataType.STRING
+        ), out);
+    }
+
+    @Test(expected = InvalidFormatStringException.class)
+    public void parseFormatString_invalid() {
+        String str = "%q";
+        LogDataType.parseFormatString(str);
+    }
+
+    @Test
+    public void logDataTypesToBitMask() {
+        List<Integer> types = Arrays.asList(LogDataType.STRING, LogDataType.DOUBLE,
+                LogDataType.LONG, LogDataType.BOOLEAN);
+        int mask = LogDataType.logDataTypesToBitMask(types);
+        assertEquals(0b11011000, mask);
+    }
+
+    @Test(expected = BitmaskConversionException.class)
+    public void logDataTypesToBitMask_toManyParams() {
+        ArrayList<Integer> types = new ArrayList<>();
+        for (int i = 0; i <= 16; i++) {
+            types.add(LogDataType.STRING);
+        }
+        LogDataType.logDataTypesToBitMask(types);
+    }
+
+    @Test
+    public void bitmaskToLogDataTypes() {
+        int bitmask = 0b11011000;
+        List<Integer> types = Arrays.asList(LogDataType.STRING, LogDataType.DOUBLE,
+                LogDataType.LONG, LogDataType.BOOLEAN);
+        for (int i = 0; i < types.size(); i++) {
+            assertEquals(types.get(i).intValue(), LogDataType.bitmaskToLogDataType(bitmask, i));
+        }
+    }
+}