[go: nahoru, domu]

Merge "Add READ_CLIPBOARD_IN_BACKGROUND to the SYSTEM_TEXT_INTELLIGENCE role" into tm-dev
diff --git a/Android.bp b/Android.bp
index a85ca71..8ec8b74 100644
--- a/Android.bp
+++ b/Android.bp
@@ -24,8 +24,7 @@
 
 apex_defaults {
     name: "com.android.permission-defaults",
-    updatable: true,
-    min_sdk_version: "30",
+    defaults: ["r-launched-apex-module"],
     bootclasspath_fragments: ["com.android.permission-bootclasspath-fragment"],
     systemserverclasspath_fragments: ["com.android.permission-systemserverclasspath-fragment"],
     prebuilts: ["current_sdkinfo"],
diff --git a/PermissionController/AndroidManifest.xml b/PermissionController/AndroidManifest.xml
index ff9c2ed..8dd4636 100644
--- a/PermissionController/AndroidManifest.xml
+++ b/PermissionController/AndroidManifest.xml
@@ -44,7 +44,8 @@
     <uses-permission android:name="android.permission.MANAGE_ONE_TIME_PERMISSION_SESSIONS" />
     <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
-    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"
+                     android:maxSdkVersion="32" />
     <uses-permission android:name="android.permission.MODIFY_AUDIO_ROUTING" />
     <uses-permission android:name="android.permission.OBSERVE_SENSOR_PRIVACY" />
     <!-- TODO(b/170896938): make this privileged(signature may only work on pixel) -->
@@ -58,6 +59,8 @@
     <uses-permission android:name="android.permission.MANAGE_SAFETY_CENTER" />
     <uses-permission android:name="android.permission.READ_SAFETY_CENTER_STATUS" />
     <uses-permission android:name="android.permission.SEND_SAFETY_CENTER_UPDATE" />
+    <!--SYSTEM_APPLICATION_OVERLAY will be granted on T+, as installer protection is added in T -->
+    <uses-permission android:name="android.permission.SYSTEM_APPLICATION_OVERLAY"/>
 
     <application android:name="com.android.permissioncontroller.PermissionControllerApplication"
             android:label="@string/app_name"
@@ -77,6 +80,13 @@
             </intent-filter>
         </receiver>
 
+        <receiver android:name="com.android.permissioncontroller.permission.service.NotificationListenerCheck$SetupPeriodicNotificationListenerCheck"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+        </receiver>
+
         <receiver android:name="com.android.permissioncontroller.hibernation.HibernationOnBootReceiver"
                   android:exported="true">
             <intent-filter>
@@ -97,9 +107,25 @@
             </intent-filter>
         </receiver>
 
+        <receiver android:name="com.android.permissioncontroller.permission.service.NotificationListenerCheck$NotificationDeleteHandler" />
+
+        <receiver android:name="com.android.permissioncontroller.permission.service.NotificationListenerCheck$NotificationClickHandler" />
+
+        <receiver android:name="com.android.permissioncontroller.permission.service.NotificationListenerCheck$PackageResetHandler"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
+                <action android:name="android.intent.action.PACKAGE_DATA_CLEARED" />
+                <data android:scheme="package" />
+            </intent-filter>
+        </receiver>
+
         <service android:name="com.android.permissioncontroller.permission.service.LocationAccessCheck$LocationAccessCheckJobService"
                  android:permission="android.permission.BIND_JOB_SERVICE" />
 
+        <service android:name="com.android.permissioncontroller.permission.service.NotificationListenerCheck$NotificationListenerCheckJobService"
+                 android:permission="android.permission.BIND_JOB_SERVICE" />
+
         <service android:name="com.android.permissioncontroller.hibernation.HibernationJobService"
                  android:permission="android.permission.BIND_JOB_SERVICE" />
 
@@ -174,7 +200,7 @@
             </intent-filter>
         </activity>
 
-        <activity android:name="com.android.permissioncontroller.permission.ui.SafetyCenterQSActivity"
+        <activity android:name="com.android.permissioncontroller.permission.ui.SafetyCenterQsActivity"
                   android:excludeFromRecents="true"
                   android:exported="true"
                   android:theme="@style/SafetyCenter"
@@ -338,7 +364,7 @@
             </intent-filter>
         </service>
 
-         <service android:name="com.android.permissioncontroller.permission.service.SafetyCenterQSTileService"
+         <service android:name="com.android.permissioncontroller.permission.service.SafetyCenterQsTileService"
                   android:exported="true"
                   android:label="@string/safety_privacy_qs_tile_title"
                   android:icon ="@drawable/safety_shield"
diff --git a/PermissionController/res/navigation/nav_graph.xml b/PermissionController/res/navigation/nav_graph.xml
index 62c25a7..cd3ba59 100644
--- a/PermissionController/res/navigation/nav_graph.xml
+++ b/PermissionController/res/navigation/nav_graph.xml
@@ -144,6 +144,18 @@
     </fragment>
 
     <fragment
+        android:id="@+id/review_permissions_dest"
+        android:name="com.android.permissioncontroller.permission.ui.handheld.ReviewPermissionsFragment"
+        android:label="ReviewAppPermissions">
+        <action
+            android:id="@+id/app_to_all_perms"
+            app:destination="@id/all_app_permissions"
+            app:enterAnim="@anim/activity_open_enter"
+            app:popExitAnim="@anim/activity_close_exit"
+            app:popEnterAnim="@anim/activity_open_enter"/>
+    </fragment>
+
+    <fragment
         android:id="@+id/all_app_permissions"
         android:name="com.android.permissioncontroller.permission.ui.handheld.AllAppPermissionsWrapperFragment"
         android:label="AllAppPermissions"/>
diff --git a/PermissionController/res/values/strings.xml b/PermissionController/res/values/strings.xml
index 9f4ed7e..850b4ec 100644
--- a/PermissionController/res/values/strings.xml
+++ b/PermissionController/res/values/strings.xml
@@ -855,6 +855,24 @@
     <!-- The notification content for background location access reminder notification [CHAR LIMIT=none] -->
     <string name="background_location_access_reminder_notification_content">This app can always access your location. Tap to change.</string>
 
+    <!-- The notification title for background location access reminder notification [DO NOT TRANSLATE] [CHAR LIMIT=60] -->
+    <string name="notification_listener_reminder_notification_title">Review app with notification listener access</string>
+
+    <!-- The notification title for background location access reminder notification [DO NOT TRANSLATE] [CHAR LIMIT=60] -->
+    <string name="notification_listener_reminder_notification_title_plural">{count, plural,
+        =1      {Review app with notification listener access}
+        other   {Review apps with notification listener access}
+        }</string>
+
+    <!-- The notification content for background location access reminder notification [DO NOT TRANSLATE] [CHAR LIMIT=none] -->
+    <string name="notification_listener_reminder_notification_content"><xliff:g id="app_name" example="Gmail">%s</xliff:g> can read all your notifications.</string>
+
+    <!-- The notification content for background location access reminder notification [DO NOT TRANSLATE] [CHAR LIMIT=none] -->
+    <string name="notification_listener_reminder_notification_content_plural">{count, plural,
+        =1      {<xliff:g id="app_name" example="Gmail">%s</xliff:g> can read all your notifications.}
+        other   {# apps can read all your notifications.}
+        }</string>
+
     <!-- The notification title for the notification that a permission auto revoke has happened [CHAR LIMIT=60] -->
     <string name="auto_revoke_after_notification_title">App permissions removed to protect privacy</string>
 
@@ -1409,15 +1427,15 @@
     <string name="permgrouprequest_sensors">Allow
         &lt;b><xliff:g id="app_name" example="Gmail">%1$s</xliff:g>&lt;/b> to access sensor data about your vital signs?</string>
 
-    <!-- Subtitle of the message shown to the user when the apps requests permission to use the body sensors while app is in foreground and background. Try to keep the link annotation at the end of the string [CHAR LIMIT=150] -->
-    <string name="permgroupupgraderequestdetail_sensors">This app wants to access sensor data about your vital signs all the time, even when you\u2019re not using the app. <annotation id="link">Allow in settings.</annotation></string>
+    <!-- Subtitle of the message shown to the user when the apps requests permission to use the body sensors while app is in foreground and background. Try to keep the link annotation at the end of the string [CHAR LIMIT=NONE] -->
+    <string name="permgroupupgraderequestdetail_sensors">This app wants to access sensor data about your vital signs all the time, even when you\u2019re not using the app. To make this change, <annotation id="link">go to settings</annotation> or keep access for only while the app is in use.</string>
     <!-- Message shown to the user when the apps requests permission to use the bosy sensors while app is in foreground and background. If ever possible this should stay below 80 characters (assuming the parameters takes 20 characters). Don't abbreviate until the message reaches 120 characters though. [CHAR LIMIT=120] -->
     <string name="permgroupbackgroundrequest_sensors">Allow
         &lt;b><xliff:g id="app_name" example="Gmail">%1$s</xliff:g>&lt;/b> to access the sensor data about your vital signs?</string>
-    <!-- Subtitle of the message shown to the user when the apps requests permission to use the body sensors while app is in foreground and background. Try to keep the link annotation at the end of the string [CHAR LIMIT=150] -->
-    <string name="permgroupbackgroundrequestdetail_sensors">This app may want to access the sensor data about your vital signs all the time, even when you\u2019re not using the app. <annotation id="link">Allow in settings.</annotation></string>
+    <!-- Subtitle of the message shown to the user when the apps requests permission to use the body sensors while app is in foreground and background. Try to keep the link annotation at the end of the string [CHAR LIMIT=NONE] -->
+    <string name="permgroupbackgroundrequestdetail_sensors">To let this app access body sensor data all the time, even when you\u2019rere not using the app, <annotation id="link">go to settings.</annotation></string>
     <!-- Message shown to the user when the apps requests permission to use the body sensors while app is in foreground and background. If ever possible this should stay below 80 characters (assuming the parameters takes 20 characters). Don't abbreviate until the message reaches 120 characters though. [CHAR LIMIT=120] -->
-    <string name="permgroupupgraderequest_sensors">Change access to sensor data about your vital signs for &lt;b><xliff:g id="app_name" example="Gmail">%1$s</xliff:g>&lt;/b>?</string>
+    <string name="permgroupupgraderequest_sensors">Keep allowing &lt;b><xliff:g id="app_name" example="Gmail">%1$s</xliff:g>&lt;/b> to access body sensor data while app is in use?</string>
 
     <!-- Message shown to the user when the apps requests permission from this group. If ever possible this should stay below 80 characters (assuming the parameters takes 20 characters). Don't abbreviate until the message reaches 120 characters though. [CHAR LIMIT=120] -->
     <string name="permgrouprequest_notifications">Allow
diff --git a/PermissionController/res/xml/roles.xml b/PermissionController/res/xml/roles.xml
index 7c02b5e..c61989c 100644
--- a/PermissionController/res/xml/roles.xml
+++ b/PermissionController/res/xml/roles.xml
@@ -1256,4 +1256,20 @@
             </preferred-activity>
         </preferred-activities>
     </role>
+
+    <!---
+      ~ A role for the package that handles AI features for the settings app
+      -->
+    <role
+        name="android.app.role.SETTINGS_INTELLIGENCE"
+        defaultHolders="config_settingsIntelligencePackageName"
+        exclusive="true"
+        minSdkVersion="33"
+        systemOnly="true"
+        visible="false"
+        overrideUserWhenGranting="true">
+        <permissions>
+            <permission-set name="notifications" />
+        </permissions>
+    </role>
 </roles>
diff --git a/PermissionController/src/com/android/permissioncontroller/Constants.java b/PermissionController/src/com/android/permissioncontroller/Constants.java
index ab0c0d5..1a12499 100644
--- a/PermissionController/src/com/android/permissioncontroller/Constants.java
+++ b/PermissionController/src/com/android/permissioncontroller/Constants.java
@@ -48,7 +48,19 @@
     public static final int OLD_PERMISSION_DECISION_CLEANUP_JOB_ID = 3;
 
     /**
-     * Name of file to containing the packages we already showed a notificaiton for.
+     * ID for the periodic job in
+     * {@link com.android.permissioncontroller.permission.service.NotificationListenerCheck}.
+     */
+    public static final int PERIODIC_NOTIFICATION_LISTENER_CHECK_JOB_ID = 4;
+
+    /**
+     * ID for the on-demand, but delayed job in
+     * {@link com.android.permissioncontroller.permission.service.NotificationListenerCheck}.
+     */
+    public static final int NOTIFICATION_LISTENER_CHECK_JOB_ID = 5;
+
+    /**
+     * Name of file to containing the packages we already showed a notification for.
      *
      * @see com.android.permissioncontroller.permission.service.LocationAccessCheck
      */
@@ -56,6 +68,14 @@
             "packages_already_notified_location_access";
 
     /**
+     * Name of file to containing the packages we already showed a notification for.
+     *
+     * @see com.android.permissioncontroller.permission.service.NotificationListenerCheck
+     */
+    public static final String NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE =
+            "packages_already_notified_notification_listener";
+
+    /**
      * ID for notification shown by
      * {@link com.android.permissioncontroller.permission.service.LocationAccessCheck}.
      */
@@ -74,6 +94,12 @@
     public static final int PERMISSION_DECISION_REMINDER_NOTIFICATION_ID = 2;
 
     /**
+     * ID for notification shown by
+     * {@link com.android.permissioncontroller.permission.service.NotificationListenerCheck}.
+     */
+    public static final int NOTIFICATION_LISTENER_CHECK_NOTIFICATION_ID = 3;
+
+    /**
      * String action for navigating to the auto revoke screen.
      */
     public static final String ACTION_MANAGE_AUTO_REVOKE = "manageAutoRevoke";
@@ -96,6 +122,7 @@
     /**
      * Channel of the notifications shown by
      * {@link com.android.permissioncontroller.permission.service.LocationAccessCheck},
+     * {@link com.android.permissioncontroller.permission.service.NotificationListenerCheck},
      * {@link com.android.permissioncontroller.hibernation.HibernationPolicyKt}, and
      * {@link com.android.permissioncontroller.auto.DrivingDecisionReminderService}
      */
@@ -121,6 +148,13 @@
             "last_location_access_notification_shown";
 
     /**
+     * Key in the generic shared preferences that stores when the last notification was shown by
+     * {@link com.android.permissioncontroller.permission.service.NotificationListenerCheck}
+     */
+    public static final String KEY_LAST_NOTIFICATION_LISTENER_NOTIFICATION_SHOWN =
+            "last_notification_listener_notification_shown";
+
+    /**
      * Key in the generic shared preferences that stores if the user manually selected the "none"
      * role holder for a role.
      */
diff --git a/PermissionController/src/com/android/permissioncontroller/hibernation/HibernationPolicy.kt b/PermissionController/src/com/android/permissioncontroller/hibernation/HibernationPolicy.kt
index fca6f1f..15693d4 100644
--- a/PermissionController/src/com/android/permissioncontroller/hibernation/HibernationPolicy.kt
+++ b/PermissionController/src/com/android/permissioncontroller/hibernation/HibernationPolicy.kt
@@ -429,8 +429,8 @@
         return true
     }
 
+    val context = PermissionControllerApplication.get()
     if (SdkLevel.isAtLeastS()) {
-        val context = PermissionControllerApplication.get()
         val hasInstallOrUpdatePermissions =
                 context.checkPermission(
                         Manifest.permission.INSTALL_PACKAGES, -1 /* pid */, pkg.uid) ==
@@ -464,6 +464,17 @@
         }
     }
 
+    if (SdkLevel.isAtLeastT()) {
+        val roleHolders = context.getSystemService(android.app.role.RoleManager::class.java)!!
+            .getRoleHolders(RoleManager.ROLE_DEVICE_POLICY_MANAGEMENT)
+        if (roleHolders.contains(pkg.packageName)) {
+            if (DEBUG_HIBERNATION_POLICY) {
+                DumpableLog.i(LOG_TAG, "Exempted ${pkg.packageName} - device policy manager app")
+            }
+            return true
+        }
+    }
+
     return false
 }
 
diff --git a/PermissionController/src/com/android/permissioncontroller/incident/ConfirmationActivity.java b/PermissionController/src/com/android/permissioncontroller/incident/ConfirmationActivity.java
index cd9b9a5..b5beb99 100644
--- a/PermissionController/src/com/android/permissioncontroller/incident/ConfirmationActivity.java
+++ b/PermissionController/src/com/android/permissioncontroller/incident/ConfirmationActivity.java
@@ -38,6 +38,7 @@
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.permissioncontroller.R;
 
 import java.util.ArrayList;
@@ -197,6 +198,11 @@
                 .create();
         if (Settings.canDrawOverlays(this)) {
             final Window w = dialog.getWindow();
+            if (SdkLevel.isAtLeastT()) {
+                WindowManager.LayoutParams lpm = new WindowManager.LayoutParams();
+                lpm.setSystemApplicationOverlay(true);
+                w.setAttributes(lpm);
+            }
             w.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
         }
         dialog.show();
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/data/LauncherPackagesLiveData.kt b/PermissionController/src/com/android/permissioncontroller/permission/data/LauncherPackagesLiveData.kt
index da0f260..f0d8118 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/data/LauncherPackagesLiveData.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/data/LauncherPackagesLiveData.kt
@@ -19,6 +19,7 @@
 import android.content.Intent
 import android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE
 import android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE
+import android.content.pm.PackageManager.FEATURE_LEANBACK
 import com.android.permissioncontroller.PermissionControllerApplication
 import kotlinx.coroutines.Job
 
@@ -31,14 +32,26 @@
     private val LAUNCHER_INTENT = Intent(Intent.ACTION_MAIN, null)
         .addCategory(Intent.CATEGORY_LAUNCHER)
 
+    // On ATV some apps may have a leanback launcher icon but no regular launcher icon
+    private val LEANBACK_LAUNCHER_INTENT = Intent(Intent.ACTION_MAIN, null)
+        .addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
+
     override suspend fun loadDataAndPostValue(job: Job) {
         val launcherPkgs = mutableSetOf<String>()
+
+        loadPkgsFromIntent(launcherPkgs, LAUNCHER_INTENT)
+        if (PermissionControllerApplication.get().packageManager
+                        .hasSystemFeature(FEATURE_LEANBACK)) {
+            loadPkgsFromIntent(launcherPkgs, LEANBACK_LAUNCHER_INTENT)
+        }
+        postValue(launcherPkgs)
+    }
+
+    private fun loadPkgsFromIntent(launcherPkgs: MutableSet<String>, intent: Intent) {
         for (info in PermissionControllerApplication.get().packageManager.queryIntentActivities(
-            LAUNCHER_INTENT, MATCH_DIRECT_BOOT_AWARE or MATCH_DIRECT_BOOT_UNAWARE)) {
+            intent, MATCH_DIRECT_BOOT_AWARE or MATCH_DIRECT_BOOT_UNAWARE)) {
             launcherPkgs.add(info.activityInfo.packageName)
         }
-
-        postValue(launcherPkgs)
     }
 
     override fun onPackageUpdate(packageName: String) {
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/model/livedatatypes/LightAppPermGroup.kt b/PermissionController/src/com/android/permissioncontroller/permission/model/livedatatypes/LightAppPermGroup.kt
index 1f3a96d..3ab059f 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/model/livedatatypes/LightAppPermGroup.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/model/livedatatypes/LightAppPermGroup.kt
@@ -139,6 +139,21 @@
     val isGrantedByRole = foreground.isGrantedByRole || background.isGrantedByRole
 
     /**
+     * Whether any of the permission (foreground/background) is fixed by the system
+     */
+    val isSystemFixed = foreground.isSystemFixed || background.isSystemFixed
+
+    /**
+     * Whether any of the permission (foreground/background) in this group requires a review
+     */
+    val isReviewRequired = foreground.isReviewRequired || background.isReviewRequired
+
+    /**
+     * Whether any of the permission (foreground/background) is granted in this permission group
+     */
+    var isGranted = foreground.isGranted || background.isGranted
+
+    /**
      * Whether any permissions in this group are user sensitive
      */
     val isUserSensitive = permissions.any { it.value.isUserSensitive }
@@ -156,7 +171,7 @@
             permissions.any { it.value.isReviewRequired }
 
     /**
-     * A subset of the AppPermssionGroup, representing either the background or foreground permissions
+     * A subset of the AppPermissionGroup, representing either the background or foreground permissions
      * of the full group.
      *
      * @param permissions The permissions contained within this subgroup, a subset of those contained
@@ -206,6 +221,11 @@
         val isUserSet = permissions.any { it.value.isUserSet }
 
         /**
+         * whether review is required or not for the permission group
+         */
+        val isReviewRequired = permissions.any { it.value.isReviewRequired }
+
+        /**
          * Whether any of this App Permission Subgroup's permissions are set by the role of this app
          */
         val isGrantedByRole = permissions.any { it.value.isGrantedByRole }
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/service/NotificationListenerCheck.kt b/PermissionController/src/com/android/permissioncontroller/permission/service/NotificationListenerCheck.kt
new file mode 100644
index 0000000..1f1bcd0
--- /dev/null
+++ b/PermissionController/src/com/android/permissioncontroller/permission/service/NotificationListenerCheck.kt
@@ -0,0 +1,873 @@
+/*
+ * Copyright (C) 2022 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.permissioncontroller.permission.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.app.PendingIntent.FLAG_ONE_SHOT
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.app.job.JobInfo
+import android.app.job.JobParameters
+import android.app.job.JobScheduler
+import android.app.job.JobService
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.content.Intent.EXTRA_COMPONENT_NAME
+import android.content.SharedPreferences
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.os.UserHandle
+import android.os.UserManager
+import android.provider.DeviceConfig
+import android.service.notification.StatusBarNotification
+import android.util.ArraySet
+import android.util.Log
+import androidx.annotation.GuardedBy
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.WorkerThread
+import androidx.core.util.Preconditions
+import com.android.modules.utils.build.SdkLevel
+import com.android.permissioncontroller.Constants
+import com.android.permissioncontroller.Constants.EXTRA_SESSION_ID
+import com.android.permissioncontroller.Constants.INVALID_SESSION_ID
+import com.android.permissioncontroller.Constants.KEY_LAST_NOTIFICATION_LISTENER_NOTIFICATION_SHOWN
+import com.android.permissioncontroller.Constants.NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE
+import com.android.permissioncontroller.Constants.NOTIFICATION_LISTENER_CHECK_NOTIFICATION_ID
+import com.android.permissioncontroller.Constants.PERIODIC_NOTIFICATION_LISTENER_CHECK_JOB_ID
+import com.android.permissioncontroller.Constants.PREFERENCES_FILE
+import com.android.permissioncontroller.R
+import com.android.permissioncontroller.permission.utils.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Dispatchers.Default
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.BufferedWriter
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.lang.System.currentTimeMillis
+import java.util.concurrent.TimeUnit.DAYS
+import java.util.function.BooleanSupplier
+import java.util.Random
+
+private val TAG = NotificationListenerCheck::class.java.simpleName
+private const val DEBUG = false
+
+/**
+ * Device config property for whether notification listener check is enabled on the device
+ */
+@VisibleForTesting
+const val PROPERTY_NOTIFICATION_LISTENER_CHECK_ENABLED = "notification_listener_check_enabled"
+
+/**
+ * Device config property for time period in milliseconds after which current enabled notification
+ * listeners are queried
+ */
+private const val PROPERTY_NOTIFICATION_LISTENER_CHECK_INTERVAL_MILLIS =
+    "notification_listener_check_interval_millis"
+
+/**
+ * Device config property for time period in milliseconds after which a followup notification can be
+ * posted for an enabled notification listener
+ */
+private const val PROPERTY_NOTIFICATION_LISTENER_CHECK_PACKAGE_INTERVAL_MILLIS =
+    "notification_listener_check_pkg_interval_millis"
+
+private val DEFAULT_NOTIFICATION_LISTENER_CHECK_INTERVAL_MILLIS = DAYS.toMillis(1)
+
+private val DEFAULT_NOTIFICATION_LISTENER_CHECK_PACKAGE_INTERVAL_MILLIS = DAYS.toMillis(90)
+
+private fun checkNotificationListenerCheckEnabled(): Boolean {
+    return DeviceConfig.getBoolean(
+        DeviceConfig.NAMESPACE_PRIVACY,
+        PROPERTY_NOTIFICATION_LISTENER_CHECK_ENABLED,
+        false
+    )
+}
+
+private fun checkNotificationListenerSupported(): Boolean {
+    return SdkLevel.isAtLeastT()
+}
+
+/**
+ * Get time in between two periodic checks.
+ *
+ * Default: 1 day
+ *
+ * @return The time in between check in milliseconds
+ */
+private fun getPeriodicCheckIntervalMillis(): Long {
+    return DeviceConfig.getLong(
+        DeviceConfig.NAMESPACE_PRIVACY,
+        PROPERTY_NOTIFICATION_LISTENER_CHECK_INTERVAL_MILLIS,
+        DEFAULT_NOTIFICATION_LISTENER_CHECK_INTERVAL_MILLIS
+    )
+}
+
+/**
+ * Flexibility of the periodic check.
+ *
+ *
+ * 10% of [.getPeriodicCheckIntervalMillis]
+ *
+ * @return The flexibility of the periodic check in milliseconds
+ */
+private fun getFlexForPeriodicCheckMillis(): Long {
+    return getPeriodicCheckIntervalMillis() / 10
+}
+
+/**
+ * Minimum time in between showing two notifications.
+ *
+ *
+ * This is just small enough so that the periodic check can always show a notification.
+ *
+ * @return The minimum time in milliseconds
+ */
+private fun getInBetweenNotificationsMillis(): Long {
+    return getPeriodicCheckIntervalMillis() - (getFlexForPeriodicCheckMillis() * 2.1).toLong()
+}
+
+/**
+ * Get time in between two notifications for a single package with enabled notification listener.
+ *
+ * Default: 90 days
+ *
+ * @return The time in between notifications for single package in milliseconds
+ */
+private fun getPackageNotificationIntervalMillis(): Long {
+    return DeviceConfig.getLong(
+        DeviceConfig.NAMESPACE_PRIVACY,
+        PROPERTY_NOTIFICATION_LISTENER_CHECK_PACKAGE_INTERVAL_MILLIS,
+        DEFAULT_NOTIFICATION_LISTENER_CHECK_PACKAGE_INTERVAL_MILLIS
+    )
+}
+
+/**
+ * Show notification that double-guesses the user if they really wants to grant notification
+ * listener permission to an app.
+ *
+ * <p>A notification is scheduled periodically, or on demand
+ *
+ * <p>We rate limit the number of notification we show and only ever show one notification at a
+ * time.
+ *
+ * <p>As there are many cases why a notification should not been shown, we always schedule a
+ * {@link #addNotificationListenerNotificationIfNeeded check} which then might add a notification.
+ *
+ * @param context Used to resolve managers
+ * @param shouldCancel If supplied, can be used to interrupt long-running operations
+ */
+class NotificationListenerCheck(
+    context: Context,
+    private val shouldCancel: BooleanSupplier?
+) {
+    private val parentUserContext = Utils.getParentUserContext(context)
+    private val packageManager: PackageManager = parentUserContext.packageManager
+    private val random = Random()
+    private val sharedPrefs: SharedPreferences =
+        parentUserContext.getSharedPreferences(PREFERENCES_FILE, MODE_PRIVATE)
+    private val userManager: UserManager =
+        Utils.getSystemServiceSafe(parentUserContext, UserManager::class.java)
+
+    companion object {
+        /** Lock required for all public methods */
+        private val nlsLock = Mutex()
+    }
+
+    /**
+     * Check for enabled notification listeners and notify user if needed.
+     *
+     * <p>Always run async inside a {@NotificationListenerCheckJobService} via coroutine.
+     */
+    @WorkerThread
+    internal suspend fun getEnabledNotificationListenersAndNotifyIfNeeded(
+        params: JobParameters,
+        service: NotificationListenerCheckJobService
+    ) {
+        if (!checkNotificationListenerCheckEnabled()) {
+            if (DEBUG) Log.d(TAG, "NotificationListenerCheck disabled, finishing job")
+            service.jobFinished(params, false)
+            return
+        }
+
+        if (!isRunningInParentProfile()) {
+            // Profile parent handles child profiles too.
+            if (DEBUG) Log.d(TAG, "NotificationListenerCheck only supported from parent profile")
+            return
+        }
+
+        nlsLock.withLock {
+            try {
+                getEnabledNotificationListenersAndNotifyIfNeededLocked()
+                service.jobFinished(params, false)
+            } catch (e: Exception) {
+                Log.e(TAG, "Could not check for notification listeners", e)
+                service.jobFinished(params, true)
+            } finally {
+                service.clearJob()
+            }
+        }
+    }
+
+    @Throws(InterruptedException::class)
+    private suspend fun getEnabledNotificationListenersAndNotifyIfNeededLocked() {
+        val enabledComponents: List<ComponentName> = getEnabledNotificationListeners()
+
+        // Load already notified components
+        // Filter to only those that still have enabled listeners
+        // Filter to those within notification interval (e.g. 90 days)
+        val notifiedComponents = loadNotifiedComponentsLocked()
+            .filterTo(ArraySet()) { componentHasBeenNotifiedWithinInterval(it) }
+            .map { it.componentName }
+
+        // Filter to unnotified components
+        val unNotifiedComponents = enabledComponents.filter { it !in notifiedComponents }
+
+        if (DEBUG) {
+            Log.v(TAG, "Found ${enabledComponents.size} enabled notification listeners. " +
+                "${notifiedComponents.size} already notified. ${unNotifiedComponents.size} " +
+                "unnotified")
+        }
+
+        throwInterruptedExceptionIfTaskIsCanceled()
+
+        postSystemNotificationIfNeeded(unNotifiedComponents)
+
+        // TODO(b/217566029): send list of components with enabled notification listeners to
+        //  Safety Center
+    }
+
+    /**
+     * Get the [components][ComponentName] which have enabled notification listeners for the
+     * parent/context user
+     *
+     * @throws InterruptedException If [.shouldCancel]
+     */
+    @Throws(InterruptedException::class)
+    private fun getEnabledNotificationListeners(): List<ComponentName> {
+        // Get all enabled NotificationListenerService components for primary user. NLS from managed
+        // profiles are never bound.
+        val enabledNotificationListeners =
+            Utils.getSystemServiceSafe(parentUserContext, NotificationManager::class.java)
+                .enabledNotificationListeners
+
+        if (DEBUG) {
+            Log.d(TAG, "enabledNotificationListeners = " +
+                "$enabledNotificationListeners")
+        }
+
+        throwInterruptedExceptionIfTaskIsCanceled()
+        return enabledNotificationListeners
+    }
+
+    private fun componentHasBeenNotifiedWithinInterval(component: NlsComponent): Boolean {
+        val interval = currentTimeMillis() - component.notificationShownTime
+        if (DEBUG) {
+            Log.v(TAG, "$interval ms since last notification of ${component.componentName}. " +
+                "pkgInterval=${getPackageNotificationIntervalMillis()}")
+        }
+        return interval < getPackageNotificationIntervalMillis()
+    }
+
+    /**
+     * Load the list of [components][NlsComponent] we have already shown a notification for.
+     *
+     * @return The list of components we have already shown a notification for.
+     */
+    @WorkerThread
+    @VisibleForTesting
+    internal suspend fun loadNotifiedComponentsLocked(): ArraySet<NlsComponent> {
+        return withContext(Dispatchers.IO) {
+            try {
+                BufferedReader(
+                    InputStreamReader(
+                        parentUserContext.openFileInput(
+                            NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE
+                        )
+                    )
+                ).use { reader ->
+                    val nlsComponents = ArraySet<NlsComponent>()
+
+                    /*
+                     * The format of the file is
+                     * <flattened component> <time of notification> <time resolved>
+                     * e.g.
+                     *
+                     * com.one.package/Class 1234567890 1234567890
+                     * com.two.package/Class 1234567890 1234567890
+                     * com.three.package/Class 1234567890 1234567890
+                     */
+                    while (true) {
+                        val line = reader.readLine() ?: break
+                        val lineComponents = line.split(" ".toRegex()).toTypedArray()
+                        val componentName = ComponentName.unflattenFromString(lineComponents[0])
+                        val notificationShownTime: Long = lineComponents[1].toLong()
+                        val signalResolvedTime: Long = lineComponents[2].toLong()
+                        if (componentName != null) {
+                            nlsComponents.add(
+                                NlsComponent(
+                                    componentName,
+                                    notificationShownTime,
+                                    signalResolvedTime
+                                )
+                            )
+                        } else {
+                            Log.i(
+                                TAG,
+                                "Not restoring state \"$line\" as component is unknown"
+                            )
+                        }
+                    }
+                    return@withContext nlsComponents
+                }
+            } catch (ignored: FileNotFoundException) {
+                return@withContext ArraySet<NlsComponent>()
+            } catch (e: Exception) {
+                Log.w(TAG, "Could not read $NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE", e)
+                return@withContext ArraySet<NlsComponent>()
+            }
+        }
+    }
+
+    /**
+     * Persist the list of [components][NlsComponent] we have already shown a notification for.
+     *
+     * @param packages The list of packages we have already shown a notification for.
+     */
+    @WorkerThread
+    private suspend fun persistNotifiedComponentsLocked(
+        nlsComponents: Collection<NlsComponent>
+    ) {
+        withContext(Dispatchers.IO) {
+            try {
+                BufferedWriter(
+                    OutputStreamWriter(
+                        parentUserContext.openFileOutput(
+                            NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE,
+                            MODE_PRIVATE
+                        )
+                    )
+                ).use { writer ->
+                    /*
+                     * The format of the file is
+                     * <flattened component> <time of notification> <time resolved>
+                     * e.g.
+                     *
+                     * com.one.package/Class 1234567890 1234567890
+                     * com.two.package/Class 1234567890 1234567890
+                     * com.three.package/Class 1234567890 1234567890
+                     */
+                    for (nlsComponent in nlsComponents) {
+                        writer.append(nlsComponent.componentName.flattenToString())
+                            .append(' ')
+                            .append(nlsComponent.notificationShownTime.toString())
+                            .append(' ')
+                            .append(nlsComponent.signalResolvedTime.toString())
+                        writer.newLine()
+                    }
+                }
+            } catch (e: IOException) {
+                Log.e(TAG, "Could not write $NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE", e)
+            }
+        }
+    }
+
+    /**
+     * Remove all persisted state for a package.
+     *
+     * @param pkg name of package
+     */
+    internal suspend fun removePackageState(pkg: String) {
+        nlsLock.withLock {
+            removeNotificationsForPackage(pkg)
+
+            // There can be multiple NLS components per package
+            // Remove all known NLS components for the specified package
+            val notifiedComponents: ArraySet<NlsComponent> = loadNotifiedComponentsLocked()
+                .filterNotTo(ArraySet()) { it.componentName.packageName == pkg }
+
+            // Persist the resulting set
+            persistNotifiedComponentsLocked(notifiedComponents)
+        }
+    }
+
+    /**
+     * Remember that we showed a notification for a [ComponentName]
+     *
+     * @param componentName The [ComponentName] we notified for
+     */
+    internal suspend fun markAsNotified(componentName: ComponentName) {
+        nlsLock.withLock {
+            val notifiedComponentsMap: MutableMap<ComponentName, NlsComponent> =
+                loadNotifiedComponentsLocked()
+                    .associateBy({ it.componentName }, { it })
+                    .toMutableMap()
+
+            // NlsComponent don't compare timestamps, so remove existing NlsComponent if present and
+            // then add again
+            val currentComponent: NlsComponent? = notifiedComponentsMap.remove(componentName)
+            val componentToMarkNotified: NlsComponent =
+                if (currentComponent != null) {
+                    // Copy the current notified component and only update the notificationShownTime
+                    currentComponent.copy(notificationShownTime = currentTimeMillis())
+                } else {
+                    // No previoulys notified component, create new one
+                    NlsComponent(componentName, notificationShownTime = currentTimeMillis())
+                }
+            notifiedComponentsMap[componentName] = componentToMarkNotified
+            persistNotifiedComponentsLocked(notifiedComponentsMap.values)
+        }
+    }
+
+    @Throws(InterruptedException::class)
+    private suspend fun postSystemNotificationIfNeeded(components: List<ComponentName>) {
+        val componentsInternal = components.toMutableList()
+
+        // Don't show too many notification within certain timespan
+        if (currentTimeMillis() - sharedPrefs.getLong(
+                KEY_LAST_NOTIFICATION_LISTENER_NOTIFICATION_SHOWN, 0
+            )
+            < getInBetweenNotificationsMillis()
+        ) {
+            Log.v(TAG, "Notification not posted, within " +
+                "$DEFAULT_NOTIFICATION_LISTENER_CHECK_INTERVAL_MILLIS ms")
+            return
+        }
+
+        // Check for existing notification first, exit if one already present
+        if (getCurrentlyShownNotificationLocked() != null) {
+            Log.v(TAG, "Notification not posted, previous notification has not been dismissed")
+            return
+        }
+
+        // Get a random package and resolve package info
+        var pkgInfo: PackageInfo? = null
+        var componentToNotifyFor: ComponentName? = null
+        while (pkgInfo == null || componentToNotifyFor == null) {
+            throwInterruptedExceptionIfTaskIsCanceled()
+
+            if (componentsInternal.isEmpty()) {
+                Log.v(TAG, "Notification not posted, no unnotified enabled listeners")
+                return
+            }
+
+            componentToNotifyFor = componentsInternal[random.nextInt(componentsInternal.size)]
+            try {
+                if (DEBUG) {
+                    Log.v(TAG, "Attempting to get PackageInfo for " +
+                        "${componentToNotifyFor.packageName}")
+                }
+                pkgInfo = getPackageInfoForComponentName(componentToNotifyFor)
+            } catch (e: PackageManager.NameNotFoundException) {
+                if (DEBUG) {
+                    Log.w(TAG, "${componentToNotifyFor.packageName} not found")
+                }
+                componentsInternal.remove(componentToNotifyFor)
+            }
+        }
+
+        createPermissionReminderChannel()
+        createNotificationForNotificationListener(componentToNotifyFor, pkgInfo)
+    }
+
+    /**
+     * Get [PackageInfo] for this ComponentName.
+     *
+     * @param component component to get package info for
+     * @return The package info
+     *
+     * @throws PackageManager.NameNotFoundException if package does not exist
+     */
+    @Throws(PackageManager.NameNotFoundException::class)
+    private fun getPackageInfoForComponentName(component: ComponentName): PackageInfo {
+        return packageManager.getPackageInfo(component.packageName, 0)
+    }
+
+    /** Create the channel the notification listener notifications should be posted to. */
+    private fun createPermissionReminderChannel() {
+        val permissionReminderChannel = NotificationChannel(
+            Constants.PERMISSION_REMINDER_CHANNEL_ID,
+            parentUserContext.getString(R.string.permission_reminders),
+            NotificationManager.IMPORTANCE_LOW
+        )
+
+        val notificationManager = Utils.getSystemServiceSafe(
+            parentUserContext,
+            NotificationManager::class.java
+        )
+
+        notificationManager.createNotificationChannel(permissionReminderChannel)
+    }
+
+    /**
+     * Create a notification reminding the user that a package has an enabled notification listener.
+     * From this notification the user can directly go to Safety Center to assess issue.
+     *
+     * @param component the [NlsComponent] of the Notification Listener
+     * @param pkg The [PackageInfo] for the [ComponentName] package
+     */
+    private fun createNotificationForNotificationListener(
+        componentName: ComponentName,
+        pkg: PackageInfo
+    ) {
+        val pkgLabel: CharSequence = packageManager.getApplicationLabel(pkg.applicationInfo)
+        val pkgName = pkg.packageName
+
+        var sessionId = INVALID_SESSION_ID
+        while (sessionId == INVALID_SESSION_ID) {
+            sessionId = Random().nextLong()
+        }
+
+        val deleteIntent = Intent(parentUserContext, NotificationDeleteHandler::class.java).apply {
+            putExtra(EXTRA_COMPONENT_NAME, componentName)
+            putExtra(EXTRA_SESSION_ID, sessionId)
+            flags = Intent.FLAG_RECEIVER_FOREGROUND
+        }
+
+        val clickIntent = Intent(parentUserContext, NotificationClickHandler::class.java).apply {
+            putExtra(EXTRA_COMPONENT_NAME, componentName)
+            putExtra(EXTRA_SESSION_ID, sessionId)
+            flags = Intent.FLAG_RECEIVER_FOREGROUND
+        }
+
+        val title =
+            parentUserContext.getString(R.string.notification_listener_reminder_notification_title)
+        val text =
+            parentUserContext.getString(
+                R.string.notification_listener_reminder_notification_content,
+                pkgLabel
+            )
+
+        val b: Notification.Builder =
+            Notification.Builder(parentUserContext, Constants.PERMISSION_REMINDER_CHANNEL_ID)
+                .setLocalOnly(true)
+                .setContentTitle(title)
+                .setContentText(text)
+                // Ensure entire text can be displayed, instead of being truncated to one line
+                .setStyle(Notification.BigTextStyle().bigText(text))
+                // TODO(b/213357911): replace with Safety Center resource
+                .setSmallIcon(R.drawable.ic_pin_drop)
+                // TODO(b/213357911): replace with Safety Center resource
+                .setColor(
+                    parentUserContext.getColor(R.color.safety_center_info))
+                .setAutoCancel(true)
+                .setDeleteIntent(
+                    PendingIntent.getBroadcast(
+                        parentUserContext, 0, deleteIntent,
+                        FLAG_ONE_SHOT or FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
+                    )
+                )
+                .setContentIntent(
+                    PendingIntent.getBroadcast(
+                        parentUserContext, 0, clickIntent,
+                        FLAG_ONE_SHOT or FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
+                    )
+                )
+
+        // TODO(b/213357911): replace with Safety Center resource
+        val appName = Utils.getSettingsLabelForNotifications(packageManager)
+        if (appName != null) {
+            val extras = Bundle()
+            extras.putString(Notification.EXTRA_SUBSTITUTE_APP_NAME, appName.toString())
+            b.addExtras(extras)
+        }
+
+        val notificationManager =
+            Utils.getSystemServiceSafe(parentUserContext, NotificationManager::class.java)
+        notificationManager.notify(pkgName, NOTIFICATION_LISTENER_CHECK_NOTIFICATION_ID, b.build())
+
+        Log.v(TAG, "Notification listener check notification shown with sessionId=" +
+            "$sessionId component=${componentName.flattenToString()}"
+        )
+
+        sharedPrefs.edit().putLong(
+            KEY_LAST_NOTIFICATION_LISTENER_NOTIFICATION_SHOWN,
+            currentTimeMillis()
+        ).apply()
+    }
+
+    /**
+     * Get currently shown notification. We only ever show one notification per profile group. Also
+     * only show notifications on the parent user/profile due to NotificationManager only binding
+     * non-managed NLS.
+     *
+     * @return The notification or `null` if no notification is currently shown
+     */
+    private fun getCurrentlyShownNotificationLocked(): StatusBarNotification? {
+        val notifications =
+            Utils.getSystemServiceSafe(parentUserContext, NotificationManager::class.java)
+                .activeNotifications
+
+        for (notification in notifications) {
+            if (notification.id == NOTIFICATION_LISTENER_CHECK_NOTIFICATION_ID) {
+                return notification
+            }
+        }
+        return null
+    }
+
+    /**
+     * Remove notification if present for a package
+     *
+     * @param pkg name of package
+     */
+    private suspend fun removeNotificationsForPackage(pkg: String) {
+        val notification: StatusBarNotification? = getCurrentlyShownNotificationLocked()
+        if (notification != null && notification.tag == pkg) {
+            Utils.getSystemServiceSafe(parentUserContext, NotificationManager::class.java)
+                .cancel(pkg, NOTIFICATION_LISTENER_CHECK_NOTIFICATION_ID)
+        }
+    }
+
+    /**
+     * If [.shouldCancel] throw an [InterruptedException].
+     */
+    @Throws(InterruptedException::class)
+    private fun throwInterruptedExceptionIfTaskIsCanceled() {
+        if (shouldCancel != null && shouldCancel.asBoolean) {
+            throw InterruptedException()
+        }
+    }
+
+    /**
+     * Check if the current user is the profile parent.
+     *
+     * @return `true` if the current user is the profile parent.
+     */
+    private fun isRunningInParentProfile(): Boolean {
+        val user = UserHandle.of(UserHandle.myUserId())
+        val parent: UserHandle? = userManager.getProfileParent(user)
+        return parent == null || user == parent
+    }
+
+    /**
+     * Checks if a new notification should be shown.
+     */
+    class NotificationListenerCheckJobService : JobService() {
+        private var notificationListenerCheck: NotificationListenerCheck? = null
+        private val jobLock = Object()
+
+        /** We currently check if we should show a notification, the task executing the check  */
+        @GuardedBy("jobLock")
+        private var addNotificationListenerNotificationIfNeededJob: Job? = null
+
+        override fun onCreate() {
+            super.onCreate()
+            notificationListenerCheck = NotificationListenerCheck(this, BooleanSupplier {
+                synchronized(jobLock) {
+                    val job = addNotificationListenerNotificationIfNeededJob
+                    return@BooleanSupplier job?.isCancelled ?: false
+                }
+            })
+        }
+
+        /**
+         * Starts an asynchronous check if a notification listener notification should be shown.
+         *
+         * @param params Not used other than for interacting with job scheduling
+         *
+         * @return `false` iff another check if already running
+         */
+        override fun onStartJob(params: JobParameters): Boolean {
+            synchronized(jobLock) {
+                if (addNotificationListenerNotificationIfNeededJob != null) {
+                    if (DEBUG) Log.d(TAG, "Job already running")
+                    return false
+                }
+                addNotificationListenerNotificationIfNeededJob = GlobalScope.launch(Default) {
+                    notificationListenerCheck?.getEnabledNotificationListenersAndNotifyIfNeeded(
+                        params,
+                        this@NotificationListenerCheckJobService
+                    ) ?: jobFinished(params, true)
+                }
+            }
+            return true
+        }
+
+        /**
+         * Abort the check if still running.
+         *
+         * @param params ignored
+         *
+         * @return false
+         */
+        override fun onStopJob(params: JobParameters): Boolean {
+            var job: Job?
+            synchronized(jobLock) {
+                job = if (addNotificationListenerNotificationIfNeededJob == null) {
+                    return false
+                } else {
+                    addNotificationListenerNotificationIfNeededJob
+                }
+            }
+            job?.cancel()
+            return false
+        }
+
+        fun clearJob() {
+            synchronized(jobLock) {
+                addNotificationListenerNotificationIfNeededJob = null
+            }
+        }
+    }
+
+    /**
+     * On boot set up a periodic job that starts checks.
+     */
+    class SetupPeriodicNotificationListenerCheck : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            if (!checkNotificationListenerSupported()) {
+                return
+            }
+
+            val notificationListenerCheck = NotificationListenerCheck(context, null)
+            val jobScheduler = Utils.getSystemServiceSafe(context, JobScheduler::class.java)
+
+            if (!notificationListenerCheck.isRunningInParentProfile()) {
+                // Profile parent handles child profiles too.
+                return
+            }
+
+            if (jobScheduler.getPendingJob(PERIODIC_NOTIFICATION_LISTENER_CHECK_JOB_ID) == null) {
+                val job =
+                    JobInfo.Builder(
+                        PERIODIC_NOTIFICATION_LISTENER_CHECK_JOB_ID,
+                        ComponentName(context, NotificationListenerCheckJobService::class.java)
+                    ).setPeriodic(
+                        getPeriodicCheckIntervalMillis(),
+                        getFlexForPeriodicCheckMillis()
+                    ).build()
+                val scheduleResult = jobScheduler.schedule(job)
+                if (scheduleResult != JobScheduler.RESULT_SUCCESS) {
+                    Log.e(
+                        TAG,
+                        "Could not schedule periodic notification listener check $scheduleResult"
+                    )
+                } else if (DEBUG) {
+                    Log.i(TAG, "Scheduled periodic notification listener check")
+                }
+            }
+        }
+    }
+
+    /**
+     * Show the notification listener permission switch when the notification is clicked.
+     */
+    class NotificationClickHandler() : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            val componentName =
+                Utils.getParcelableExtraSafe<ComponentName>(intent, EXTRA_COMPONENT_NAME)
+            val sessionId = intent.getLongExtra(EXTRA_SESSION_ID, INVALID_SESSION_ID)
+            GlobalScope.launch(Default) {
+                NotificationListenerCheck(context, null).markAsNotified(componentName)
+            }
+            Log.v(
+                TAG,
+                "Notification listener check notification clicked with sessionId=$sessionId " +
+                    "component=${componentName.flattenToString()}"
+            )
+
+            // TODO(b/216365468): send users to safety center
+        }
+    }
+
+    /**
+     * Handle the case where the notification is swiped away without further interaction.
+     */
+    class NotificationDeleteHandler() : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            val componentName =
+                Utils.getParcelableExtraSafe<ComponentName>(intent, EXTRA_COMPONENT_NAME)
+            val sessionId = intent.getLongExtra(EXTRA_SESSION_ID, INVALID_SESSION_ID)
+            GlobalScope.launch(Default) {
+                NotificationListenerCheck(context, null).markAsNotified(componentName)
+            }
+            Log.v(
+                TAG,
+                "Notification listener check notification declined with sessionId=$sessionId " +
+                    "component=${componentName.flattenToString()}"
+            )
+        }
+    }
+
+    /**
+     * If a package gets removed or the data of the package gets cleared, forget that we showed a
+     * notification for it.
+     */
+    class PackageResetHandler : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            val action = intent.action
+            if (action != Intent.ACTION_PACKAGE_DATA_CLEARED &&
+                action != Intent.ACTION_PACKAGE_FULLY_REMOVED
+            ) {
+                return
+            }
+
+            if (!checkNotificationListenerSupported()) {
+                return
+            }
+
+            val data = Preconditions.checkNotNull(intent.data)
+
+            if (DEBUG) Log.i(TAG, "Reset " + data.schemeSpecificPart)
+
+            GlobalScope.launch(Default) {
+                NotificationListenerCheck(context, null).run {
+                    if (!this.isRunningInParentProfile()) {
+                        if (DEBUG) {
+                            Log.d(TAG, "NotificationListenerCheck only supports parent profile")
+                        }
+                        return@run
+                    }
+
+                    removePackageState(data.schemeSpecificPart)
+
+                    // TODO(b/217566029): update Safety center action cards
+                }
+            }
+        }
+    }
+
+    /**
+     * An immutable data class containing a [ComponentName] and timestamps for notification and
+     * signal resolved.
+     *
+     * @param componentName The component name of the notification listener
+     * @param notificationShownTime optional named parameter to set time of notification shown
+     * @param signalResolvedTime optional named parameter to set time of signal resolved
+     */
+    @VisibleForTesting
+    internal data class NlsComponent(
+        val componentName: ComponentName,
+        val notificationShownTime: Long = 0L,
+        val signalResolvedTime: Long = 0L
+    )
+}
\ No newline at end of file
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQSTileService.kt b/PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQsTileService.kt
similarity index 97%
rename from PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQSTileService.kt
rename to PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQsTileService.kt
index 5ee5d6f..b25b346 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQSTileService.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/service/SafetyCenterQsTileService.kt
@@ -29,7 +29,7 @@
 /**
  * The service backing a Quick Settings Tile which will take users to the Safety Center QS Fragment.
  */
-class SafetyCenterQSTileService : TileService() {
+class SafetyCenterQsTileService : TileService() {
     private var disabled = false
 
     override fun onBind(intent: Intent?): IBinder? {
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/service/TEST_MAPPING b/PermissionController/src/com/android/permissioncontroller/permission/service/TEST_MAPPING
index 4764836..cadc93d 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/service/TEST_MAPPING
+++ b/PermissionController/src/com/android/permissioncontroller/permission/service/TEST_MAPPING
@@ -55,6 +55,9 @@
                     "include-filter": "android.permission.cts.LocationAccessCheckTest"
                 },
                 {
+                    "include-filter": "android.permission.cts.NotificationListenerCheckTest"
+                },
+                {
                     "exclude-annotation": "androidx.test.filters.FlakyTest"
                 }
             ]
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/GrantPermissionsActivity.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/GrantPermissionsActivity.java
index 2d5f653..7639c20 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/GrantPermissionsActivity.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/GrantPermissionsActivity.java
@@ -170,19 +170,8 @@
 
         getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
 
-        mRequestedPermissions = getIntent().getStringArrayExtra(
-                PackageManager.EXTRA_REQUEST_PERMISSIONS_NAMES);
-        if (mRequestedPermissions == null || mRequestedPermissions.length == 0) {
-            setResultAndFinish();
-            return;
-        }
-        mOriginalRequestedPermissions = mRequestedPermissions;
-
-        mLegacyAccessPermissions = getIntent().getStringArrayExtra(
-                PackageManager.EXTRA_REQUEST_PERMISSIONS_LEGACY_ACCESS_PERMISSION_NAMES);
-        if (mLegacyAccessPermissions == null) {
-            mLegacyAccessPermissions = new String[0];
-        }
+        mRequestedPermissions = getIntent()
+                .getStringArrayExtra(PackageManager.EXTRA_REQUEST_PERMISSIONS_NAMES);
 
         if (PackageManager.ACTION_REQUEST_PERMISSIONS_FOR_OTHER.equals(getIntent().getAction())) {
             mTargetPackage = getIntent().getStringExtra(Intent.EXTRA_PACKAGE_NAME);
@@ -200,6 +189,25 @@
         } else {
             // Cache this as this can only read on onCreate, not later.
             mTargetPackage = getCallingPackage();
+
+            // If this app is below the android T targetSdk, filter out the POST_NOTIFICATIONS
+            // permission, if present
+            mRequestedPermissions = GrantPermissionsViewModel.Companion
+                    .filterNotificationPermissionIfNeededSync(
+                            mTargetPackage, mRequestedPermissions);
+        }
+
+
+        if (mRequestedPermissions == null || mRequestedPermissions.length == 0) {
+            setResultAndFinish();
+            return;
+        }
+        mOriginalRequestedPermissions = mRequestedPermissions;
+
+        mLegacyAccessPermissions = getIntent().getStringArrayExtra(
+                PackageManager.EXTRA_REQUEST_PERMISSIONS_LEGACY_ACCESS_PERMISSION_NAMES);
+        if (mLegacyAccessPermissions == null) {
+            mLegacyAccessPermissions = new String[0];
         }
 
         synchronized (sCurrentGrantRequests) {
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/ReviewPermissionsActivity.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/ReviewPermissionsActivity.java
index 2054943..4fdf847 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/ReviewPermissionsActivity.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/ReviewPermissionsActivity.java
@@ -25,6 +25,9 @@
 
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
+import androidx.navigation.NavGraph;
+import androidx.navigation.NavInflater;
+import androidx.navigation.fragment.NavHostFragment;
 
 import com.android.permissioncontroller.DeviceUtils;
 import com.android.permissioncontroller.R;
@@ -56,11 +59,14 @@
             getSupportFragmentManager().beginTransaction()
                     .replace(android.R.id.content, fragment).commit();
         } else {
-            setContentView(R.layout.review_permissions);
-            if (getSupportFragmentManager().findFragmentById(R.id.preferences_frame) == null) {
-                getSupportFragmentManager().beginTransaction().add(R.id.preferences_frame,
-                        ReviewPermissionsFragment.newInstance(packageInfo)).commit();
-            }
+            setContentView(R.layout.nav_host_fragment);
+            NavHostFragment navHost = (NavHostFragment) getSupportFragmentManager()
+                    .findFragmentById(R.id.nav_host_fragment);
+            NavInflater inflater = navHost.getNavController().getNavInflater();
+            NavGraph graph = inflater.inflate(R.navigation.nav_graph);
+            graph.setStartDestination(R.id.review_permissions_dest);
+            navHost.getNavController().setGraph(graph,
+                    ReviewPermissionsFragment.getArgs(packageInfo));
         }
     }
 
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQSActivity.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQsActivity.java
similarity index 92%
rename from PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQSActivity.java
rename to PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQsActivity.java
index c52be4b..8bbbf3d 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQSActivity.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/SafetyCenterQsActivity.java
@@ -26,7 +26,7 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.permissioncontroller.Constants;
-import com.android.permissioncontroller.permission.ui.handheld.SafetyCenterQSFragment;
+import com.android.permissioncontroller.permission.ui.handheld.SafetyCenterQsFragment;
 
 import java.util.ArrayList;
 import java.util.Random;
@@ -34,7 +34,7 @@
 /**
  * Activity for the Safety Center Quick Settings Activity
  */
-public class SafetyCenterQSActivity extends FragmentActivity {
+public class SafetyCenterQsActivity extends FragmentActivity {
 
     @Override
     @SuppressWarnings("NewApi")
@@ -53,6 +53,6 @@
         ArrayList<PermissionGroupUsage> permissionUsages = getIntent().getParcelableArrayListExtra(
                 PermissionManager.EXTRA_PERMISSION_USAGES);
         getSupportFragmentManager().beginTransaction().replace(android.R.id.content,
-                SafetyCenterQSFragment.newInstance(sessionId, permissionUsages)).commit();
+                SafetyCenterQsFragment.newInstance(sessionId, permissionUsages)).commit();
     }
 }
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionAppsFragment.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionAppsFragment.java
index 020bf9a..d5ddd8c 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionAppsFragment.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionAppsFragment.java
@@ -262,11 +262,28 @@
         CardViewPreference sensorCard = findPreference(BLOCKED_SENSOR_PREF_KEY);
         if (sensorCard == null) {
             sensorCard = createSensorCard();
+            ensurePreferenceScreen();
             getPreferenceScreen().addPreference(sensorCard);
         }
         sensorCard.setVisible(true);
     }
 
+    private void ensurePreferenceScreen() {
+        // Check if preference screen has been already loaded
+        if (getPreferenceScreen() != null) {
+            return;
+        }
+        boolean isStorageAndLessThanT = !SdkLevel.isAtLeastT()
+                && mPermGroupName.equals(Manifest.permission_group.STORAGE);
+        if (isStorageAndLessThanT) {
+            addPreferencesFromResource(R.xml.allowed_denied_storage);
+        } else {
+            addPreferencesFromResource(R.xml.allowed_denied);
+        }
+        // Hide allowed foreground label by default, to avoid briefly showing it before updating
+        findPreference(ALLOWED_FOREGROUND.getCategoryName()).setVisible(false);
+    }
+
     @RequiresApi(Build.VERSION_CODES.S)
     private CardViewPreference createSensorCard() {
         boolean isLocation = Manifest.permission_group.LOCATION.equals(mPermGroupName);
@@ -354,15 +371,7 @@
     private void onPackagesLoaded(Map<Category, List<Pair<String, UserHandle>>> categories) {
         boolean isStorageAndLessThanT = !SdkLevel.isAtLeastT()
                 && mPermGroupName.equals(Manifest.permission_group.STORAGE);
-        if (getPreferenceScreen() == null) {
-            if (isStorageAndLessThanT) {
-                addPreferencesFromResource(R.xml.allowed_denied_storage);
-            } else {
-                addPreferencesFromResource(R.xml.allowed_denied);
-            }
-            // Hide allowed foreground label by default, to avoid briefly showing it before updating
-            findPreference(ALLOWED_FOREGROUND.getCategoryName()).setVisible(false);
-        }
+        ensurePreferenceScreen();
         Context context = getPreferenceManager().getContext();
 
         if (context == null || getActivity() == null || categories == null) {
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionPreference.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionPreference.java
index 5a02677..5d17c5e 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionPreference.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/PermissionPreference.java
@@ -20,12 +20,9 @@
 import static android.app.admin.DevicePolicyResources.Strings.PermissionController.BACKGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE;
 import static android.app.admin.DevicePolicyResources.Strings.PermissionController.FOREGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE;
 
-import static com.android.permissioncontroller.permission.utils.Utils.DEFAULT_MAX_LABEL_SIZE_PX;
 import static com.android.permissioncontroller.permission.utils.Utils.getRequestMessage;
 import static com.android.permissioncontroller.permission.utils.Utils.getSystemServiceSafe;
 
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
 import android.app.AlertDialog;
 import android.app.Dialog;
 import android.app.admin.DevicePolicyManager;
@@ -33,39 +30,35 @@
 import android.os.Bundle;
 import android.os.UserHandle;
 import android.text.BidiFormatter;
-import android.text.TextUtils;
 import android.widget.Switch;
 
-import androidx.annotation.IntDef;
 import androidx.annotation.LayoutRes;
 import androidx.fragment.app.DialogFragment;
-import androidx.fragment.app.Fragment;
 import androidx.preference.PreferenceFragmentCompat;
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.permissioncontroller.R;
-import com.android.permissioncontroller.permission.model.AppPermissionGroup;
-import com.android.permissioncontroller.permission.model.Permission;
+import com.android.permissioncontroller.permission.model.livedatatypes.LightAppPermGroup;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel.PermissionSummary;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel.PermissionTarget;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel.SummaryMessage;
 import com.android.permissioncontroller.permission.utils.LocationUtils;
 import com.android.permissioncontroller.permission.utils.Utils;
 import com.android.settingslib.RestrictedLockUtils;
 import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin;
 
-import java.lang.annotation.Retention;
-import java.util.List;
-
 /**
  * A preference for representing a permission group requested by an app.
  */
 class PermissionPreference extends MultiTargetSwitchPreference {
-    @Retention(SOURCE)
-    @IntDef(value = {CHANGE_FOREGROUND, CHANGE_BACKGROUND}, flag = true)
-    @interface ChangeTarget {}
-    static final int CHANGE_FOREGROUND = 1;
-    static final int CHANGE_BACKGROUND = 2;
-    static final int CHANGE_BOTH = CHANGE_FOREGROUND | CHANGE_BACKGROUND;
 
-    private final AppPermissionGroup mGroup;
+    /**
+     * holds state for the permission group represented by this preference.
+     */
+    private PermissionTarget mState = PermissionTarget.PERMISSION_NONE;
+    private final LightAppPermGroup mGroup;
+    private final ReviewPermissionsViewModel mViewModel;
     private final PreferenceFragmentCompat mFragment;
     private final PermissionPreferenceChangeListener mCallBacks;
     private final @LayoutRes int mOriginalWidgetLayoutRes;
@@ -95,20 +88,20 @@
 
     /**
      * Callbacks from dialogs to the fragment. These callbacks are supposed to directly cycle back
-     * to the permission tha created the dialog.
+     * to the permission that created the dialog.
      */
     interface PermissionPreferenceOwnerFragment {
         /**
          * The {@link DefaultDenyDialog} can only interact with the fragment, not the preference
          * that created it. Hence this call goes to the fragment, which then finds the preference an
-         * calls {@link #onDenyAnyWay(int)}.
+         * calls {@link #onDenyAnyWay(PermissionTarget)}.
          *
          * @param key Key uniquely identifying the preference that created the default deny dialog
          * @param changeTarget Whether background or foreground permissions should be changed
          *
-         * @see #showDefaultDenyDialog(int)
+         * @see #showDefaultDenyDialog(PermissionTarget, boolean)
          */
-        void onDenyAnyWay(String key, @ChangeTarget int changeTarget);
+        void onDenyAnyWay(String key, PermissionTarget changeTarget);
 
         /**
          * The {@link BackgroundAccessChooser} can only interact with the fragment, not the
@@ -124,76 +117,32 @@
         void onBackgroundAccessChosen(String key, int chosenItem);
     }
 
-    PermissionPreference(PreferenceFragmentCompat fragment, AppPermissionGroup group,
-            PermissionPreferenceChangeListener callbacks) {
+    PermissionPreference(PreferenceFragmentCompat fragment, LightAppPermGroup group,
+            PermissionPreferenceChangeListener callbacks,
+            ReviewPermissionsViewModel reviewPermissionsViewModel) {
         super(fragment.getPreferenceManager().getContext());
 
         mFragment = fragment;
         mGroup = group;
+        mViewModel = reviewPermissionsViewModel;
         mCallBacks = callbacks;
         mOriginalWidgetLayoutRes = getWidgetLayoutResource();
-
+        setState(group);
         setPersistent(false);
         updateUi();
     }
 
-    /**
-     * Are any permissions of this group fixed by the system, i.e. not changeable by the user.
-     *
-     * @return {@code true} iff any permission is fixed
-     */
-    private boolean isSystemFixed() {
-        return mGroup.isSystemFixed();
+    PermissionTarget getState() {
+        return mState;
     }
 
-    /**
-     * Is any foreground permissions of this group fixed by the policy, i.e. not changeable by the
-     * user.
-     *
-     * @return {@code true} iff any foreground permission is fixed
-     */
-    private boolean isForegroundPolicyFixed() {
-        return mGroup.isPolicyFixed();
-    }
-
-    /**
-     * Is any background permissions of this group fixed by the policy, i.e. not changeable by the
-     * user.
-     *
-     * @return {@code true} iff any background permission is fixed
-     */
-    private boolean isBackgroundPolicyFixed() {
-        return mGroup.getBackgroundPermissions() != null
-                && mGroup.getBackgroundPermissions().isPolicyFixed();
-    }
-
-    /**
-     * Are there permissions fixed, so that the user cannot change the preference at all?
-     *
-     * @return {@code true} iff the permissions of this group are fixed
-     */
-    private boolean isPolicyFullyFixed() {
-        return isForegroundPolicyFixed() && (mGroup.getBackgroundPermissions() == null
-                || isBackgroundPolicyFixed());
-    }
-
-    /**
-     * Is the foreground part of this group disabled. If the foreground is disabled, there is no
-     * need to possible grant background access.
-     *
-     * @return {@code true} iff the permissions of this group are fixed
-     */
-    private boolean isForegroundDisabledByPolicy() {
-        return isForegroundPolicyFixed() && !mGroup.areRuntimePermissionsGranted();
-    }
-
-    /**
-     * Get the app that acts as admin for this profile.
-     *
-     * @return The admin or {@code null} if there is no admin.
-     */
-    private EnforcedAdmin getAdmin() {
-        return RestrictedLockUtils.getProfileOrDeviceOwner(getContext(), mGroup.getUser());
+    private void setState(LightAppPermGroup appPermGroup) {
+        if (appPermGroup.isReviewRequired()) {
+            mState = PermissionTarget.PERMISSION_FOREGROUND;
+            if (appPermGroup.getHasBackgroundGroup()) {
+                mState = PermissionTarget.PERMISSION_BOTH;
+            }
+        }
     }
 
     /**
@@ -201,8 +150,9 @@
      */
     void updateUi() {
         boolean arePermissionsIndividuallyControlled =
-                Utils.areGroupPermissionsIndividuallyControlled(getContext(), mGroup.getName());
-        EnforcedAdmin admin = getAdmin();
+                Utils.areGroupPermissionsIndividuallyControlled(getContext(),
+                        mGroup.getPermGroupName());
+        EnforcedAdmin admin = mViewModel.getAdmin(getContext(), mGroup);
 
         // Reset ui state
         setEnabled(true);
@@ -211,9 +161,9 @@
         setSwitchOnClickListener(null);
         setSummary(null);
 
-        setChecked(mGroup.areRuntimePermissionsGranted());
+        setChecked(mState != PermissionTarget.PERMISSION_NONE);
 
-        if (isSystemFixed() || isPolicyFullyFixed() || isForegroundDisabledByPolicy()) {
+        if (mViewModel.isFixedOrForegroundDisabled(mGroup)) {
             if (admin != null) {
                 setWidgetLayoutResource(R.layout.restricted_icon);
 
@@ -228,13 +178,16 @@
             updateSummaryForFixedByPolicyPermissionGroup();
         } else if (arePermissionsIndividuallyControlled) {
             setOnPreferenceClickListener((pref) -> {
-                showAllPermissions(mGroup.getName());
+                Bundle args = AllAppPermissionsFragment.createArgs(mGroup.getPackageName(),
+                                mGroup.getPermGroupName(), UserHandle.getUserHandleForUid(
+                                mGroup.getPackageInfo().getUid()));
+                mViewModel.showAllPermissions(mFragment, args);
                 return false;
             });
 
             setSwitchOnClickListener(v -> {
                 Switch switchView = (Switch) v;
-                requestChange(switchView.isChecked(), CHANGE_BOTH);
+                requestChange(switchView.isChecked(), PermissionTarget.PERMISSION_BOTH);
 
                 // Update UI as the switch widget might be in wrong state
                 updateUi();
@@ -242,23 +195,26 @@
 
             updateSummaryForIndividuallyControlledPermissionGroup();
         } else {
-            if (mGroup.hasPermissionWithBackgroundMode()) {
-                if (mGroup.getBackgroundPermissions() == null) {
+            if (mGroup.getHasPermWithBackgroundMode()) {
+                if (!mGroup.getHasBackgroundGroup()) {
                     // The group has background permissions but the app did not request any. I.e.
                     // The app can only switch between 'never" and "only in foreground".
                     setOnPreferenceChangeListener((pref, newValue) ->
-                            requestChange((Boolean) newValue, CHANGE_FOREGROUND));
+                            requestChange((Boolean) newValue,
+                                    PermissionTarget.PERMISSION_FOREGROUND));
 
                     updateSummaryForPermissionGroupWithBackgroundPermission();
                 } else {
-                    if (isBackgroundPolicyFixed()) {
+                    if (mGroup.getBackground().isPolicyFixed()) {
                         setOnPreferenceChangeListener((pref, newValue) ->
-                                requestChange((Boolean) newValue, CHANGE_FOREGROUND));
+                                requestChange((Boolean) newValue,
+                                        PermissionTarget.PERMISSION_FOREGROUND));
 
                         updateSummaryForFixedByPolicyPermissionGroup();
-                    } else if (isForegroundPolicyFixed()) {
+                    } else if (mGroup.getForeground().isPolicyFixed()) {
                         setOnPreferenceChangeListener((pref, newValue) ->
-                                requestChange((Boolean) newValue, CHANGE_BACKGROUND));
+                                requestChange((Boolean) newValue,
+                                        PermissionTarget.PERMISSION_BACKGROUND));
 
                         updateSummaryForFixedByPolicyPermissionGroup();
                     } else {
@@ -275,7 +231,7 @@
                             if (switchView.isChecked()) {
                                 showBackgroundChooserDialog();
                             } else {
-                                requestChange(false, CHANGE_BOTH);
+                                requestChange(false, PermissionTarget.PERMISSION_BOTH);
                             }
 
                             // Update UI as the switch widget might be in wrong state
@@ -285,7 +241,7 @@
                 }
             } else {
                 setOnPreferenceChangeListener((pref, newValue) ->
-                        requestChange((Boolean) newValue, CHANGE_BOTH));
+                        requestChange((Boolean) newValue, PermissionTarget.PERMISSION_BOTH));
             }
         }
     }
@@ -294,27 +250,8 @@
      * Update the summary in the case the permission group has individually controlled permissions.
      */
     private void updateSummaryForIndividuallyControlledPermissionGroup() {
-        int revokedCount = 0;
-        List<Permission> permissions = mGroup.getPermissions();
-        final int permissionCount = permissions.size();
-        for (int i = 0; i < permissionCount; i++) {
-            Permission permission = permissions.get(i);
-            if (!permission.isGrantedIncludingAppOp()) {
-                revokedCount++;
-            }
-        }
-
-        final int resId;
-        if (revokedCount == 0) {
-            resId = R.string.permission_revoked_none;
-        } else if (revokedCount == permissionCount) {
-            resId = R.string.permission_revoked_all;
-        } else {
-            resId = R.string.permission_revoked_count;
-        }
-
-        String summary = getContext().getString(resId, revokedCount);
-        setSummary(summary);
+        PermissionSummary summary = mViewModel.getSummaryForIndividuallyControlledPermGroup(mGroup);
+        setSummary(getContext().getString(getResource(summary.getMsg()), summary.getRevokeCount()));
     }
 
     /**
@@ -323,105 +260,40 @@
      * <p>This does not apply to permission groups that are fixed by policy</p>
      */
     private void updateSummaryForPermissionGroupWithBackgroundPermission() {
-        AppPermissionGroup backgroundGroup = mGroup.getBackgroundPermissions();
-
-        if (mGroup.areRuntimePermissionsGranted()) {
-            if (backgroundGroup == null) {
-                setSummary(R.string.permission_access_only_foreground);
-            } else {
-                if (backgroundGroup.areRuntimePermissionsGranted()) {
-                    setSummary(R.string.permission_access_always);
-                } else {
-                    setSummary(R.string.permission_access_only_foreground);
-                }
-            }
-        } else {
-            setSummary(R.string.permission_access_never);
-        }
+        PermissionSummary summary = mViewModel.getSummaryForPermGroupWithBackgroundPermission(
+                mState);
+        setSummary(getResource(summary.getMsg()));
     }
 
     /**
      * Update the summary of a permission group that is at least partially fixed by policy.
      */
     private void updateSummaryForFixedByPolicyPermissionGroup() {
-        EnforcedAdmin admin = getAdmin();
-        AppPermissionGroup backgroundGroup = mGroup.getBackgroundPermissions();
-
-        boolean hasAdmin = admin != null;
-
-        if (isSystemFixed()) {
-            // Permission is fully controlled by the system and cannot be switched
-
-            setSummary(R.string.permission_summary_enabled_system_fixed);
-        } else if (isForegroundDisabledByPolicy()) {
-            // Permission is fully controlled by policy and cannot be switched
-
-            if (hasAdmin) {
-                setSummary(R.string.disabled_by_admin);
-            } else {
-                // Disabled state will be displayed by switch, so no need to add text for that
-                setSummary(R.string.permission_summary_enforced_by_policy);
-            }
-        } else if (isPolicyFullyFixed()) {
-            // Permission is fully controlled by policy and cannot be switched
-
-            if (backgroundGroup == null) {
-                if (hasAdmin) {
-                    setSummary(R.string.enabled_by_admin);
-                } else {
-                    // Enabled state will be displayed by switch, so no need to add text for
-                    // that
-                    setSummary(R.string.permission_summary_enforced_by_policy);
-                }
-            } else {
-                if (backgroundGroup.areRuntimePermissionsGranted()) {
-                    if (hasAdmin) {
-                        setSummary(R.string.enabled_by_admin);
-                    } else {
-                        // Enabled state will be displayed by switch, so no need to add text for
-                        // that
-                        setSummary(R.string.permission_summary_enforced_by_policy);
-                    }
-                } else {
-                    if (hasAdmin) {
-                        setSummary(
-                                R.string.permission_summary_enabled_by_admin_foreground_only);
-                    } else {
-                        setSummary(
-                                R.string.permission_summary_enabled_by_policy_foreground_only);
-                    }
-                }
+        PermissionSummary summary = mViewModel.getSummaryForFixedByPolicyPermissionGroup(mState,
+                mGroup, getContext());
+        if (summary.getMsg() == SummaryMessage.NO_SUMMARY) {
+            return;
+        }
+        if (summary.isEnterprise()) {
+            switch (summary.getMsg()) {
+                case ENABLED_BY_ADMIN_BACKGROUND_ONLY:
+                    setSummary(getEnterpriseString(BACKGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE,
+                            getResource(summary.getMsg())));
+                    break;
+                case DISABLED_BY_ADMIN_BACKGROUND_ONLY:
+                    setSummary(getEnterpriseString(BACKGROUND_ACCESS_DISABLED_BY_ADMIN_MESSAGE,
+                            getResource(summary.getMsg())));
+                    break;
+                case ENABLED_BY_ADMIN_FOREGROUND_ONLY:
+                    setSummary(getEnterpriseString(FOREGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE,
+                            getResource(summary.getMsg())));
+                    break;
+                default:
+                    throw new IllegalArgumentException("Missing enterprise summary "
+                            + "case for " + summary.getMsg());
             }
         } else {
-            // Part of the permission group can still be switched
-
-            if (isBackgroundPolicyFixed()) {
-                if (backgroundGroup.areRuntimePermissionsGranted()) {
-                    if (hasAdmin) {
-                        setSummary(getEnterpriseString(
-                                BACKGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE,
-                                R.string.permission_summary_enabled_by_admin_background_only));
-                    } else {
-                        setSummary(R.string.permission_summary_enabled_by_policy_background_only);
-                    }
-                } else {
-                    if (hasAdmin) {
-                        setSummary(getEnterpriseString(
-                                BACKGROUND_ACCESS_DISABLED_BY_ADMIN_MESSAGE,
-                                R.string.permission_summary_disabled_by_admin_background_only));
-                    } else {
-                        setSummary(R.string.permission_summary_disabled_by_policy_background_only);
-                    }
-                }
-            } else if (isForegroundPolicyFixed()) {
-                if (hasAdmin) {
-                    setSummary(getEnterpriseString(
-                            FOREGROUND_ACCESS_ENABLED_BY_ADMIN_MESSAGE,
-                            R.string.permission_summary_enabled_by_admin_foreground_only));
-                } else {
-                    setSummary(R.string.permission_summary_enabled_by_policy_foreground_only);
-                }
-            }
+            setSummary(getResource(summary.getMsg()));
         }
     }
 
@@ -432,17 +304,43 @@
                 : getContext().getString(defaultStringId);
     }
 
-
-    /**
-     * Show all individual permissions in this group in a new fragment.
-     */
-    private void showAllPermissions(String filterGroup) {
-        Fragment frag = AllAppPermissionsWrapperFragment.newInstance(mGroup.getApp().packageName,
-                filterGroup, UserHandle.getUserHandleForUid(mGroup.getApp().applicationInfo.uid));
-        mFragment.getFragmentManager().beginTransaction()
-                .replace(android.R.id.content, frag)
-                .addToBackStack("AllPerms")
-                .commit();
+    int getResource(SummaryMessage summary) {
+        switch (summary) {
+            case DISABLED_BY_ADMIN:
+                return R.string.disabled_by_admin;
+            case ENABLED_BY_ADMIN:
+                return R.string.enabled_by_admin;
+            case ENABLED_SYSTEM_FIXED:
+                return R.string.permission_summary_enabled_system_fixed;
+            case ENFORCED_BY_POLICY:
+                return R.string.permission_summary_enforced_by_policy;
+            case ENABLED_BY_ADMIN_FOREGROUND_ONLY:
+                return R.string.permission_summary_enabled_by_admin_foreground_only;
+            case ENABLED_BY_POLICY_FOREGROUND_ONLY:
+                return R.string.permission_summary_enabled_by_policy_foreground_only;
+            case ENABLED_BY_ADMIN_BACKGROUND_ONLY:
+                return R.string.permission_summary_enabled_by_admin_background_only;
+            case ENABLED_BY_POLICY_BACKGROUND_ONLY:
+                return R.string.permission_summary_enabled_by_policy_foreground_only;
+            case DISABLED_BY_ADMIN_BACKGROUND_ONLY:
+                return R.string.permission_summary_disabled_by_admin_background_only;
+            case DISABLED_BY_POLICY_BACKGROUND_ONLY:
+                return R.string.permission_summary_disabled_by_policy_background_only;
+            case REVOKED_NONE:
+                return R.string.permission_revoked_none;
+            case REVOKED_ALL:
+                return R.string.permission_revoked_all;
+            case REVOKED_COUNT:
+                return R.string.permission_revoked_count;
+            case ACCESS_ALWAYS:
+                return R.string.permission_access_always;
+            case ACCESS_ONLY_FOREGROUND:
+                return R.string.permission_access_only_foreground;
+            case ACCESS_NEVER:
+                return R.string.permission_access_never;
+            default:
+                throw new IllegalArgumentException("No resource found");
+        }
     }
 
     /**
@@ -452,12 +350,9 @@
      * @return The label of the app
      */
     private String getAppLabel() {
-        return BidiFormatter.getInstance().unicodeWrap(
-                mGroup.getApp().applicationInfo.loadSafeLabel(getContext().getPackageManager(),
-                        DEFAULT_MAX_LABEL_SIZE_PX,
-                        TextUtils.SAFE_STRING_FLAG_TRIM
-                                | TextUtils.SAFE_STRING_FLAG_FIRST_LINE)
-                        .toString());
+        String label = Utils.getAppLabel(mViewModel.getPackageInfo().applicationInfo,
+                mViewModel.getApp());
+        return BidiFormatter.getInstance().unicodeWrap(label);
     }
 
     /**
@@ -477,50 +372,37 @@
      * @param changeTarget Which permission group (foreground/background/both) should be changed
      * @return If the request was processed.
      */
-    private boolean requestChange(boolean requestGrant, @ChangeTarget int changeTarget) {
-        if (LocationUtils.isLocationGroupAndProvider(getContext(), mGroup.getName(),
-                mGroup.getApp().packageName)) {
+    private boolean requestChange(boolean requestGrant, PermissionTarget changeTarget) {
+        if (LocationUtils.isLocationGroupAndProvider(getContext(), mGroup.getPermGroupName(),
+                mGroup.getPackageName())) {
             LocationUtils.showLocationDialog(getContext(), getAppLabel());
             return false;
         }
         if (requestGrant) {
             mCallBacks.onPreferenceChanged(getKey());
-
-            if ((changeTarget & CHANGE_FOREGROUND) != 0) {
-                mGroup.grantRuntimePermissions(true, false);
-            }
-            if ((changeTarget & CHANGE_BACKGROUND) != 0) {
-                if (mGroup.getBackgroundPermissions() != null) {
-                    mGroup.getBackgroundPermissions().grantRuntimePermissions(true, false);
-                }
-            }
+            //allow additional state
+            mState = PermissionTarget.Companion.fromInt(mState.or(changeTarget));
         } else {
             boolean requestToRevokeGrantedByDefault = false;
-            if ((changeTarget & CHANGE_FOREGROUND) != 0) {
-                requestToRevokeGrantedByDefault = mGroup.hasGrantedByDefaultPermission();
+            if (changeTarget.and(PermissionTarget.PERMISSION_FOREGROUND)
+                    != PermissionTarget.PERMISSION_NONE.getValue()) {
+                requestToRevokeGrantedByDefault = mGroup.isGrantedByDefault();
             }
-            if ((changeTarget & CHANGE_BACKGROUND) != 0) {
-                if (mGroup.getBackgroundPermissions() != null) {
+            if (changeTarget.and(PermissionTarget.PERMISSION_BACKGROUND)
+                    != PermissionTarget.PERMISSION_NONE.getValue()) {
+                if (mGroup.getHasBackgroundGroup()) {
                     requestToRevokeGrantedByDefault |=
-                            mGroup.getBackgroundPermissions().hasGrantedByDefaultPermission();
+                            mGroup.getBackground().isGrantedByDefault();
                 }
             }
 
-            if ((requestToRevokeGrantedByDefault || !mGroup.doesSupportRuntimePermissions())
+            if ((requestToRevokeGrantedByDefault || !mGroup.getSupportsRuntimePerms())
                     && mCallBacks.shouldConfirmDefaultPermissionRevoke()) {
-                showDefaultDenyDialog(changeTarget);
+                showDefaultDenyDialog(changeTarget, requestToRevokeGrantedByDefault);
                 return false;
             } else {
                 mCallBacks.onPreferenceChanged(getKey());
-
-                if ((changeTarget & CHANGE_FOREGROUND) != 0) {
-                    mGroup.revokeRuntimePermissions(false);
-                }
-                if ((changeTarget & CHANGE_BACKGROUND) != 0) {
-                    if (mGroup.getBackgroundPermissions() != null) {
-                        mGroup.getBackgroundPermissions().revokeRuntimePermissions(false);
-                    }
-                }
+                mState = PermissionTarget.Companion.fromInt(mState.and(~changeTarget.getValue()));
             }
         }
 
@@ -543,28 +425,17 @@
      *
      * @param changeTarget Whether background or foreground should be changed
      */
-    private void showDefaultDenyDialog(@ChangeTarget int changeTarget) {
+    private void showDefaultDenyDialog(PermissionTarget changeTarget,
+            boolean showGrantedByDefaultWarning) {
         if (!mFragment.isResumed()) {
             return;
         }
 
         Bundle args = new Bundle();
-
-        boolean showGrantedByDefaultWarning = false;
-        if ((changeTarget & CHANGE_FOREGROUND) != 0) {
-            showGrantedByDefaultWarning = mGroup.hasGrantedByDefaultPermission();
-        }
-        if ((changeTarget & CHANGE_BACKGROUND) != 0) {
-            if (mGroup.getBackgroundPermissions() != null) {
-                showGrantedByDefaultWarning |=
-                        mGroup.getBackgroundPermissions().hasGrantedByDefaultPermission();
-            }
-        }
-
         args.putInt(DefaultDenyDialog.MSG, showGrantedByDefaultWarning ? R.string.system_warning
                 : R.string.old_sdk_deny_warning);
         args.putString(DefaultDenyDialog.KEY, getKey());
-        args.putInt(DefaultDenyDialog.CHANGE_TARGET, changeTarget);
+        args.putInt(DefaultDenyDialog.CHANGE_TARGET, changeTarget.getValue());
 
         DefaultDenyDialog deaultDenyDialog = new DefaultDenyDialog();
         deaultDenyDialog.setArguments(args);
@@ -588,21 +459,21 @@
             return;
         }
 
-        if (LocationUtils.isLocationGroupAndProvider(getContext(), mGroup.getName(),
-                mGroup.getApp().packageName)) {
+        if (LocationUtils.isLocationGroupAndProvider(getContext(), mGroup.getPermGroupName(),
+                mGroup.getPackageName())) {
             LocationUtils.showLocationDialog(getContext(), getAppLabel());
             return;
         }
 
         Bundle args = new Bundle();
         args.putCharSequence(BackgroundAccessChooser.TITLE,
-                getRequestMessage(getAppLabel(), mGroup.getApp().packageName, mGroup.getName(),
-                        getContext(), mGroup.getRequest()));
+                getRequestMessage(getAppLabel(), mGroup.getPackageName(), mGroup.getPermGroupName(),
+                        getContext(), Utils.getRequest(mGroup.getPermGroupName())));
         args.putString(BackgroundAccessChooser.KEY, getKey());
 
 
-        if (mGroup.areRuntimePermissionsGranted()) {
-            if (mGroup.getBackgroundPermissions().areRuntimePermissionsGranted()) {
+        if (mState != PermissionTarget.PERMISSION_NONE) {
+            if (mState == PermissionTarget.PERMISSION_BOTH) {
                 args.putInt(BackgroundAccessChooser.SELECTION,
                         BackgroundAccessChooser.ALWAYS_OPTION);
             } else {
@@ -623,25 +494,28 @@
      * Once we user has confirmed that he/she wants to revoke a permission that was granted by
      * default, actually revoke the permissions.
      *
-     * @see #showDefaultDenyDialog(int)
+     * @see #showDefaultDenyDialog(PermissionTarget, boolean)
      */
-    void onDenyAnyWay(@ChangeTarget int changeTarget) {
+    void onDenyAnyWay(PermissionTarget changeTarget) {
         mCallBacks.onPreferenceChanged(getKey());
 
         boolean hasDefaultPermissions = false;
-        if ((changeTarget & CHANGE_FOREGROUND) != 0) {
-            mGroup.revokeRuntimePermissions(false);
-            hasDefaultPermissions = mGroup.hasGrantedByDefaultPermission();
+        if (changeTarget.and(PermissionTarget.PERMISSION_FOREGROUND)
+                != PermissionTarget.PERMISSION_NONE.getValue()) {
+            hasDefaultPermissions = mGroup.isGrantedByDefault();
+            mState = PermissionTarget.Companion.fromInt(mState.and(
+                    ~PermissionTarget.PERMISSION_FOREGROUND.getValue()));
         }
-        if ((changeTarget & CHANGE_BACKGROUND) != 0) {
-            if (mGroup.getBackgroundPermissions() != null) {
-                mGroup.getBackgroundPermissions().revokeRuntimePermissions(false);
-                hasDefaultPermissions |=
-                        mGroup.getBackgroundPermissions().hasGrantedByDefaultPermission();
+        if (changeTarget.and(PermissionTarget.PERMISSION_BACKGROUND)
+                != PermissionTarget.PERMISSION_NONE.getValue()) {
+            if (mGroup.getHasBackgroundGroup()) {
+                hasDefaultPermissions |= mGroup.getBackground().isGrantedByDefault();
+                mState = PermissionTarget.Companion.fromInt(mState.and(
+                        ~PermissionTarget.PERMISSION_BACKGROUND.getValue()));
             }
         }
 
-        if (hasDefaultPermissions || !mGroup.doesSupportRuntimePermissions()) {
+        if (hasDefaultPermissions || !mGroup.getSupportsRuntimePerms()) {
             mCallBacks.hasConfirmDefaultPermissionRevoke();
         }
         updateUi();
@@ -656,22 +530,21 @@
      * @param choosenItem The item that the user chose
      */
     void onBackgroundAccessChosen(int choosenItem) {
-        AppPermissionGroup backgroundGroup = mGroup.getBackgroundPermissions();
 
         switch (choosenItem) {
             case BackgroundAccessChooser.ALWAYS_OPTION:
-                requestChange(true, CHANGE_BOTH);
+                requestChange(true, PermissionTarget.PERMISSION_BOTH);
                 break;
             case BackgroundAccessChooser.FOREGROUND_ONLY_OPTION:
-                if (backgroundGroup.areRuntimePermissionsGranted()) {
-                    requestChange(false, CHANGE_BACKGROUND);
+                if (mState.and(PermissionTarget.PERMISSION_BACKGROUND)
+                        != PermissionTarget.PERMISSION_NONE.getValue()) {
+                    requestChange(false, PermissionTarget.PERMISSION_BACKGROUND);
                 }
-                requestChange(true, CHANGE_FOREGROUND);
+                requestChange(true, PermissionTarget.PERMISSION_FOREGROUND);
                 break;
             case BackgroundAccessChooser.NEVER_OPTION:
-                if (mGroup.areRuntimePermissionsGranted()
-                        || mGroup.getBackgroundPermissions().areRuntimePermissionsGranted()) {
-                    requestChange(false, CHANGE_BOTH);
+                if (mState != PermissionTarget.PERMISSION_NONE) {
+                    requestChange(false, PermissionTarget.PERMISSION_BOTH);
                 }
                 break;
         }
@@ -681,7 +554,7 @@
      * A dialog warning the user that she/he is about to deny a permission that was granted by
      * default.
      *
-     * @see #showDefaultDenyDialog(int)
+     * @see #showDefaultDenyDialog(PermissionTarget, boolean)
      */
     public static class DefaultDenyDialog extends DialogFragment {
         private static final String MSG = DefaultDenyDialog.class.getName() + ".arg.msg";
@@ -698,7 +571,8 @@
                             (DialogInterface dialog, int which) -> (
                                     (PermissionPreferenceOwnerFragment) getParentFragment())
                                     .onDenyAnyWay(getArguments().getString(KEY),
-                                            getArguments().getInt(CHANGE_TARGET)));
+                                            PermissionTarget.Companion.fromInt(
+                                                    getArguments().getInt(CHANGE_TARGET))));
 
             return b.create();
         }
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/ReviewPermissionsFragment.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/ReviewPermissionsFragment.java
index 260f1a2..5e5c221 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/ReviewPermissionsFragment.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/ReviewPermissionsFragment.java
@@ -22,6 +22,8 @@
 import static com.android.permissioncontroller.PermissionControllerStatsLog.REVIEW_PERMISSIONS_FRAGMENT_RESULT_REPORTED;
 
 import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
 import android.content.Intent;
 import android.content.IntentSender;
 import android.content.pm.PackageInfo;
@@ -34,12 +36,16 @@
 import android.text.Spanned;
 import android.text.TextUtils;
 import android.util.Log;
+import android.view.LayoutInflater;
 import android.view.View;
+import android.view.ViewGroup;
 import android.widget.Button;
 import android.widget.ImageView;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.ViewModelProvider;
 import androidx.preference.Preference;
 import androidx.preference.PreferenceCategory;
 import androidx.preference.PreferenceFragmentCompat;
@@ -48,15 +54,18 @@
 
 import com.android.permissioncontroller.PermissionControllerStatsLog;
 import com.android.permissioncontroller.R;
-import com.android.permissioncontroller.permission.model.AppPermissionGroup;
-import com.android.permissioncontroller.permission.model.AppPermissions;
-import com.android.permissioncontroller.permission.model.Permission;
+import com.android.permissioncontroller.permission.model.livedatatypes.LightAppPermGroup;
+import com.android.permissioncontroller.permission.model.livedatatypes.LightPermission;
 import com.android.permissioncontroller.permission.ui.ManagePermissionsActivity;
-import com.android.permissioncontroller.permission.utils.ArrayUtils;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionViewModelFactory;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel;
+import com.android.permissioncontroller.permission.ui.model.ReviewPermissionsViewModel.PermissionTarget;
+import com.android.permissioncontroller.permission.utils.KotlinUtils;
 import com.android.permissioncontroller.permission.utils.Utils;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Random;
 
 /**
@@ -71,27 +80,25 @@
             "com.android.permissioncontroller.permission.ui.extra.PACKAGE_INFO";
     private static final String LOG_TAG = ReviewPermissionsFragment.class.getSimpleName();
 
-    private AppPermissions mAppPermissions;
-
+    private ReviewPermissionsViewModel mViewModel;
+    private View mView;
     private Button mContinueButton;
     private Button mCancelButton;
     private Button mMoreInfoButton;
-
     private PreferenceCategory mNewPermissionsCategory;
     private PreferenceCategory mCurrentPermissionsCategory;
 
     private boolean mHasConfirmedRevoke;
 
     /**
-     * @return a new fragment
+     * Creates bundle arguments for the navigation graph
+     * @param packageInfo packageInfo added to the bundle
+     * @return the bundle
      */
-    public static ReviewPermissionsFragment newInstance(PackageInfo packageInfo) {
+    public static Bundle getArgs(PackageInfo packageInfo) {
         Bundle arguments = new Bundle();
         arguments.putParcelable(EXTRA_PACKAGE_INFO, packageInfo);
-        ReviewPermissionsFragment instance = new ReviewPermissionsFragment();
-        instance.setArguments(arguments);
-        instance.setRetainInstance(true);
-        return instance;
+        return arguments;
     }
 
     @Override
@@ -109,25 +116,26 @@
             return;
         }
 
-        mAppPermissions = new AppPermissions(activity, packageInfo, false, true,
-                () -> getActivity().finishAfterTransition());
-
-        boolean reviewRequired = false;
-        for (AppPermissionGroup group : mAppPermissions.getPermissionGroups()) {
-            if (group.isReviewRequired() || (group.getBackgroundPermissions() != null
-                    && group.getBackgroundPermissions().isReviewRequired())) {
-                reviewRequired = true;
-                break;
-            }
-        }
-
-        if (!reviewRequired) {
-            // If the system called for a review but no groups are found, this means that all groups
-            // are restricted. Hence there is nothing to review and instantly continue.
-            confirmPermissionsReview();
-            executeCallback(true);
-            activity.finishAfterTransition();
-        }
+        ReviewPermissionViewModelFactory factory = new ReviewPermissionViewModelFactory(
+                getActivity().getApplication(), packageInfo);
+        mViewModel = new ViewModelProvider(this, factory).get(ReviewPermissionsViewModel.class);
+        mViewModel.getPermissionGroupsLiveData().observe(this,
+                (Map<String, LightAppPermGroup> permGroupsMap) -> {
+                    if (getActivity().isFinishing()) {
+                        return;
+                    }
+                    if (permGroupsMap.isEmpty()) {
+                        //If the system called for a review but no groups are found, this means
+                        // that all groups are restricted. Hence there is nothing to review
+                        // and instantly continue.
+                        confirmPermissionsReview();
+                        executeCallback(true);
+                        activity.finishAfterTransition();
+                    } else {
+                        bindUi(permGroupsMap);
+                        loadPreferences(permGroupsMap);
+                    }
+                });
     }
 
     @Override
@@ -136,16 +144,13 @@
     }
 
     @Override
-    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-        bindUi();
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-        mAppPermissions.refresh();
-        loadPreferences();
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+            @Nullable Bundle savedInstanceState) {
+        mView = inflater.inflate(R.layout.review_permissions, container, false);
+        ViewGroup preferenceRootView = mView.requireViewById(R.id.preferences_frame);
+        View prefsContainer = super.onCreateView(inflater, preferenceRootView, savedInstanceState);
+        preferenceRootView.addView(prefsContainer);
+        return mView;
     }
 
     @Override
@@ -163,30 +168,15 @@
         } else if (view == mMoreInfoButton) {
             Intent intent = new Intent(Intent.ACTION_MANAGE_APP_PERMISSIONS);
             intent.putExtra(Intent.EXTRA_PACKAGE_NAME,
-                    mAppPermissions.getPackageInfo().packageName);
+                    mViewModel.getPackageInfo().packageName);
             intent.putExtra(Intent.EXTRA_USER, UserHandle.getUserHandleForUid(
-                    mAppPermissions.getPackageInfo().applicationInfo.uid));
+                    mViewModel.getPackageInfo().applicationInfo.uid));
             intent.putExtra(ManagePermissionsActivity.EXTRA_ALL_PERMISSIONS, true);
             getActivity().startActivity(intent);
         }
         activity.finishAfterTransition();
     }
 
-    private void grantReviewedPermission(AppPermissionGroup group) {
-        String[] permissionsToGrant = null;
-        final int permissionCount = group.getPermissions().size();
-        for (int j = 0; j < permissionCount; j++) {
-            final Permission permission = group.getPermissions().get(j);
-            if (permission.isReviewRequired()) {
-                permissionsToGrant = ArrayUtils.appendString(
-                        permissionsToGrant, permission.getName());
-            }
-        }
-        if (permissionsToGrant != null) {
-            group.grantRuntimePermissions(true, false, permissionsToGrant);
-        }
-    }
-
     private void confirmPermissionsReview() {
         final List<PreferenceGroup> preferenceGroups = new ArrayList<>();
         if (mNewPermissionsCategory != null) {
@@ -201,7 +191,7 @@
 
         final int preferenceGroupCount = preferenceGroups.size();
         long changeIdForLogging = new Random().nextLong();
-
+        Application app = getActivity().getApplication();
         for (int groupNum = 0; groupNum < preferenceGroupCount; groupNum++) {
             final PreferenceGroup preferenceGroup = preferenceGroups.get(groupNum);
 
@@ -211,31 +201,32 @@
                 if (preference instanceof PermissionReviewPreference) {
                     PermissionReviewPreference permPreference =
                             (PermissionReviewPreference) preference;
-                    AppPermissionGroup group = permPreference.getGroup();
+                    LightAppPermGroup group = permPreference.getGroup();
 
-                    // If the preference wasn't toggled we show it as "granted"
-                    if (group.isReviewRequired() && !permPreference.wasChanged()) {
-                        grantReviewedPermission(group);
+
+                    if (permPreference.getState().and(
+                            PermissionTarget.PERMISSION_FOREGROUND)
+                            != PermissionTarget.PERMISSION_NONE.getValue()) {
+                        KotlinUtils.INSTANCE.grantForegroundRuntimePermissions(app, group);
+                    }
+                    if (permPreference.getState().and(
+                            PermissionTarget.PERMISSION_BACKGROUND)
+                            != PermissionTarget.PERMISSION_NONE.getValue()) {
+                        KotlinUtils.INSTANCE.grantBackgroundRuntimePermissions(app, group);
+                    }
+                    if (permPreference.getState() == PermissionTarget.PERMISSION_NONE) {
+                        KotlinUtils.INSTANCE.revokeForegroundRuntimePermissions(app, group);
+                        KotlinUtils.INSTANCE.revokeBackgroundRuntimePermissions(app, group);
                     }
                     logReviewPermissionsFragmentResult(changeIdForLogging, group);
-
-                    AppPermissionGroup backgroundGroup = group.getBackgroundPermissions();
-                    if (backgroundGroup != null) {
-                        // If the preference wasn't toggled we show it as "fully granted"
-                        if (backgroundGroup.isReviewRequired() && !permPreference.wasChanged()) {
-                            grantReviewedPermission(backgroundGroup);
-                        }
-                        logReviewPermissionsFragmentResult(changeIdForLogging, backgroundGroup);
-                    }
                 }
             }
         }
-        mAppPermissions.persistChanges(true);
 
         // Some permission might be restricted and hence there is no AppPermissionGroup for it.
         // Manually unset all review-required flags, regardless of restriction.
         PackageManager pm = getContext().getPackageManager();
-        PackageInfo pkg = mAppPermissions.getPackageInfo();
+        PackageInfo pkg = mViewModel.getPackageInfo();
         UserHandle user = UserHandle.getUserHandleForUid(pkg.applicationInfo.uid);
 
         if (pkg.requestedPermissions == null) {
@@ -255,58 +246,59 @@
         }
     }
 
-    private void logReviewPermissionsFragmentResult(long changeId, AppPermissionGroup group) {
-        ArrayList<Permission> permissions = group.getPermissions();
+    private void logReviewPermissionsFragmentResult(long changeId, LightAppPermGroup group) {
+        ArrayList<LightPermission> permissions = new ArrayList<>(
+                group.getAllPermissions().values());
 
         int numPermissions = permissions.size();
         for (int i = 0; i < numPermissions; i++) {
-            Permission permission = permissions.get(i);
+            LightPermission permission = permissions.get(i);
 
             PermissionControllerStatsLog.write(REVIEW_PERMISSIONS_FRAGMENT_RESULT_REPORTED,
-                    changeId, group.getApp().applicationInfo.uid, group.getApp().packageName,
+                    changeId, mViewModel.getPackageInfo().applicationInfo.uid,
+                    group.getPackageName(),
                     permission.getName(), permission.isGrantedIncludingAppOp());
             Log.v(LOG_TAG, "Permission grant via permission review changeId=" + changeId + " uid="
-                    + group.getApp().applicationInfo.uid + " packageName="
-                    + group.getApp().packageName + " permission="
+                    + mViewModel.getPackageInfo().applicationInfo.uid + " packageName="
+                    + group.getPackageName() + " permission="
                     + permission.getName() + " granted=" + permission.isGrantedIncludingAppOp());
         }
     }
 
-    private void bindUi() {
+    private void bindUi(Map<String, LightAppPermGroup> permGroupsMap) {
         Activity activity = getActivity();
-        if (activity == null) {
+        if (activity == null || !mViewModel.isInitialized()) {
             return;
         }
 
-        // Set icon
-        Drawable icon = mAppPermissions.getPackageInfo().applicationInfo.loadIcon(
-                activity.getPackageManager());
-        ImageView iconView = activity.requireViewById(R.id.app_icon);
+        Drawable icon = mViewModel.getPackageInfo().applicationInfo.loadIcon(
+                    getContext().getPackageManager());
+        ImageView iconView = mView.requireViewById(R.id.app_icon);
         iconView.setImageDrawable(icon);
 
         // Set message
-        final int labelTemplateResId = isPackageUpdated()
+        final int labelTemplateResId = mViewModel.isPackageUpdated()
                 ? R.string.permission_review_title_template_update
                 : R.string.permission_review_title_template_install;
         Spanned message = Html.fromHtml(getString(labelTemplateResId,
-                mAppPermissions.getAppLabel()), 0);
-
+                Utils.getAppLabel(mViewModel.getPackageInfo().applicationInfo,
+                        getActivity().getApplication())), 0);
         // Set the permission message as the title so it can be announced.
         activity.setTitle(message.toString());
 
         // Color the app name.
-        TextView permissionsMessageView = activity.requireViewById(
+        TextView permissionsMessageView = mView.requireViewById(
                 R.id.permissions_message);
         permissionsMessageView.setText(message);
 
-        mContinueButton = getActivity().requireViewById(R.id.continue_button);
+        mContinueButton = mView.requireViewById(R.id.continue_button);
         mContinueButton.setOnClickListener(this);
 
-        mCancelButton = getActivity().requireViewById(R.id.cancel_button);
+        mCancelButton = mView.requireViewById(R.id.cancel_button);
         mCancelButton.setOnClickListener(this);
 
         if (activity.getPackageManager().arePermissionsIndividuallyControlled()) {
-            mMoreInfoButton = getActivity().requireViewById(
+            mMoreInfoButton = mView.requireViewById(
                     R.id.permission_more_info_button);
             mMoreInfoButton.setOnClickListener(this);
             mMoreInfoButton.setVisibility(View.VISIBLE);
@@ -316,21 +308,21 @@
     private PermissionReviewPreference getPreference(String key) {
         if (mNewPermissionsCategory != null) {
             PermissionReviewPreference pref =
-                    (PermissionReviewPreference) mNewPermissionsCategory.findPreference(key);
+                    mNewPermissionsCategory.findPreference(key);
 
             if (pref == null && mCurrentPermissionsCategory != null) {
-                return (PermissionReviewPreference) mCurrentPermissionsCategory.findPreference(key);
+                return mCurrentPermissionsCategory.findPreference(key);
             } else {
                 return pref;
             }
         } else {
-            return (PermissionReviewPreference) getPreferenceScreen().findPreference(key);
+            return getPreferenceScreen().findPreference(key);
         }
     }
 
-    private void loadPreferences() {
+    private void loadPreferences(Map<String, LightAppPermGroup> permGroupsMap) {
         Activity activity = getActivity();
-        if (activity == null) {
+        if (activity == null || !mViewModel.isInitialized()) {
             return;
         }
 
@@ -345,30 +337,24 @@
         mCurrentPermissionsCategory = null;
         mNewPermissionsCategory = null;
 
-        final boolean isPackageUpdated = isPackageUpdated();
+        final boolean isPackageUpdated = mViewModel.isPackageUpdated();
 
-        for (AppPermissionGroup group : mAppPermissions.getPermissionGroups()) {
-            if (!Utils.shouldShowPermission(getContext(), group)
-                    || !Utils.OS_PKG.equals(group.getDeclaringPackage())) {
-                continue;
-            }
-
-            PermissionReviewPreference preference = getPreference(group.getName());
+        for (LightAppPermGroup group : permGroupsMap.values()) {
+            PermissionReviewPreference preference = getPreference(group.getPermGroupName());
             if (preference == null) {
-                preference = new PermissionReviewPreference(this, group, this);
-
-                preference.setKey(group.getName());
-                Drawable icon = Utils.loadDrawable(activity.getPackageManager(),
-                        group.getIconPkg(), group.getIconResId());
-                preference.setIcon(Utils.applyTint(getContext(), icon,
-                        android.R.attr.colorControlNormal));
-                preference.setTitle(group.getLabel());
+                preference = new PermissionReviewPreference(this,
+                        group, this, mViewModel);
+                preference.setKey(group.getPermGroupName());
+                Drawable icon = KotlinUtils.INSTANCE.getPermGroupIcon(getContext(),
+                        group.getPermGroupName());
+                preference.setIcon(icon);
+                preference.setTitle(KotlinUtils.INSTANCE.getPermGroupLabel(getContext(),
+                        group.getPermGroupName()));
             } else {
                 preference.updateUi();
             }
 
-            if (group.isReviewRequired() || (group.getBackgroundPermissions() != null
-                    && group.getBackgroundPermissions().isReviewRequired())) {
+            if (group.isReviewRequired()) {
                 if (!isPackageUpdated) {
                     screen.addPreference(preference);
                 } else {
@@ -392,19 +378,6 @@
         }
     }
 
-    private boolean isPackageUpdated() {
-        List<AppPermissionGroup> groups = mAppPermissions.getPermissionGroups();
-        final int groupCount = groups.size();
-        for (int i = 0; i < groupCount; i++) {
-            AppPermissionGroup group = groups.get(i);
-            if (!(group.isReviewRequired() || (group.getBackgroundPermissions() != null
-                    && group.getBackgroundPermissions().isReviewRequired()))) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     private void executeCallback(boolean success) {
         Activity activity = getActivity();
         if (activity == null) {
@@ -454,7 +427,7 @@
     }
 
     @Override
-    public void onDenyAnyWay(String key, int changeTarget) {
+    public void onDenyAnyWay(String key, PermissionTarget changeTarget) {
         getPreference(key).onDenyAnyWay(changeTarget);
     }
 
@@ -473,18 +446,20 @@
      * </ul>
      */
     private static class PermissionReviewPreference extends PermissionPreference {
-        private final AppPermissionGroup mGroup;
+        private final LightAppPermGroup mGroup;
+        private final Context mContext;
         private boolean mWasChanged;
 
-        PermissionReviewPreference(PreferenceFragmentCompat fragment, AppPermissionGroup group,
-                PermissionPreferenceChangeListener callbacks) {
-            super(fragment, group, callbacks);
-
+        PermissionReviewPreference(PreferenceFragmentCompat fragment, LightAppPermGroup group,
+                PermissionPreferenceChangeListener callbacks,
+                ReviewPermissionsViewModel reviewPermissionsViewModel) {
+            super(fragment, group, callbacks, reviewPermissionsViewModel);
             mGroup = group;
+            mContext = fragment.getContext();
             updateUi();
         }
 
-        AppPermissionGroup getGroup() {
+        LightAppPermGroup getGroup() {
             return mGroup;
         }
 
@@ -496,13 +471,6 @@
             updateUi();
         }
 
-        /**
-         * @return {@code true} iff the permission was changed by the user
-         */
-        boolean wasChanged() {
-            return mWasChanged;
-        }
-
         @Override
         void updateUi() {
             // updateUi might be called in super-constructor before group is initialized
@@ -514,12 +482,14 @@
 
             if (isEnabled()) {
                 if (mGroup.isReviewRequired() && !mWasChanged) {
-                    setSummary(mGroup.getDescription());
+                    setSummary(KotlinUtils.INSTANCE.getPermGroupDescription(mContext,
+                            mGroup.getPermGroupName()));
                     setCheckedOverride(true);
                 } else if (TextUtils.isEmpty(getSummary())) {
                     // Sometimes the summary is already used, e.g. when this for a
                     // foreground/background group. In this case show leave the original summary.
-                    setSummary(mGroup.getDescription());
+                    setSummary(KotlinUtils.INSTANCE.getPermGroupDescription(mContext,
+                            mGroup.getPermGroupName()));
                 }
             }
         }
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQSFragment.java b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQsFragment.java
similarity index 98%
rename from PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQSFragment.java
rename to PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQsFragment.java
index c74cece..8d10dd5 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQSFragment.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/handheld/SafetyCenterQsFragment.java
@@ -68,7 +68,7 @@
  * mic/camera/location toggles.
  */
 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-public class SafetyCenterQSFragment extends Fragment {
+public class SafetyCenterQsFragment extends Fragment {
     private static final ArrayMap<String, Integer> sToggleButtons = new ArrayMap<>();
 
     private long mSessionId;
@@ -87,12 +87,12 @@
      * @param sessionId The current session Id
      * @return A bundle with the required arguments
      */
-    public static SafetyCenterQSFragment newInstance(long sessionId,
+    public static SafetyCenterQsFragment newInstance(long sessionId,
             ArrayList<PermissionGroupUsage> usages) {
         Bundle args = new Bundle();
         args.putLong(EXTRA_SESSION_ID, sessionId);
         args.putParcelableArrayList(PermissionManager.EXTRA_PERMISSION_USAGES, usages);
-        SafetyCenterQSFragment frag = new SafetyCenterQSFragment();
+        SafetyCenterQsFragment frag = new SafetyCenterQsFragment();
         frag.setArguments(args);
         return frag;
     }
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/model/AllAppPermissionsViewModel.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/AllAppPermissionsViewModel.kt
index 86cec5d..157c745 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/model/AllAppPermissionsViewModel.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/AllAppPermissionsViewModel.kt
@@ -56,10 +56,12 @@
             addSource(packagePermsLiveData) {
                 update()
             }
-            update()
         }
 
         override fun onUpdate() {
+            if (!packagePermsLiveData.isInitialized || packagePermsLiveData.isStale) {
+                return
+            }
             val permissions = packagePermsLiveData.value
             if (permissions == null) {
                 value = null
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/model/GrantPermissionsViewModel.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/GrantPermissionsViewModel.kt
index dbcb34f..73db006 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/model/GrantPermissionsViewModel.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/GrantPermissionsViewModel.kt
@@ -19,6 +19,7 @@
 import android.Manifest
 import android.Manifest.permission.ACCESS_COARSE_LOCATION
 import android.Manifest.permission.ACCESS_FINE_LOCATION
+import android.Manifest.permission.POST_NOTIFICATIONS
 import android.Manifest.permission_group.LOCATION
 import android.app.Activity
 import android.app.Application
@@ -38,6 +39,7 @@
 import androidx.lifecycle.ViewModelProvider
 import com.android.permissioncontroller.Constants
 import com.android.permissioncontroller.DeviceUtils
+import com.android.permissioncontroller.PermissionControllerApplication
 import com.android.permissioncontroller.PermissionControllerStatsLog
 import com.android.permissioncontroller.PermissionControllerStatsLog.GRANT_PERMISSIONS_ACTIVITY_BUTTON_ACTIONS
 import com.android.permissioncontroller.PermissionControllerStatsLog.PERMISSION_GRANT_REQUEST_RESULT_REPORTED__RESULT__AUTO_DENIED
@@ -1263,6 +1265,27 @@
             STORAGE_SUPERGROUP_MESSAGE_Q_TO_S(7),
             STORAGE_SUPERGROUP_MESSAGE_PRE_Q(8);
         }
+
+        fun filterNotificationPermissionIfNeededSync(
+            packageName: String,
+            permissions: Array<String>?
+        ): Array<String>? {
+            if (permissions == null) {
+                return null
+            }
+
+            try {
+                val targetSdk = PermissionControllerApplication.get().packageManager
+                        .getPackageInfo(packageName, 0).applicationInfo.targetSdkVersion
+                if (targetSdk > Build.VERSION_CODES.S_V2) {
+                    return permissions
+                }
+            } catch (e: PackageManager.NameNotFoundException) {
+                return permissions
+            }
+
+            return permissions.toList().filter { it != POST_NOTIFICATIONS }.toTypedArray()
+        }
     }
 }
 
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/model/ReviewPermissionsViewModel.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/ReviewPermissionsViewModel.kt
new file mode 100644
index 0000000..40e4dee
--- /dev/null
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/model/ReviewPermissionsViewModel.kt
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2022 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.permissioncontroller.permission.ui.model
+
+import android.app.Application
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.os.Bundle
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import com.android.permissioncontroller.R
+import com.android.permissioncontroller.permission.data.LightAppPermGroupLiveData
+import com.android.permissioncontroller.permission.data.PackagePermissionsLiveData
+import com.android.permissioncontroller.permission.data.PackagePermissionsLiveData.Companion.NON_RUNTIME_NORMAL_PERMS
+import com.android.permissioncontroller.permission.data.SmartUpdateMediatorLiveData
+import com.android.permissioncontroller.permission.data.get
+import com.android.permissioncontroller.permission.model.livedatatypes.LightAppPermGroup
+import com.android.permissioncontroller.permission.utils.Utils
+import com.android.permissioncontroller.permission.utils.navigateSafe
+import com.android.settingslib.RestrictedLockUtils
+import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin
+import java.util.stream.Collectors
+
+/**
+ * View model for legacy {@link ReviewPermissionsFragment}.
+ */
+class ReviewPermissionsViewModel(
+    val app: Application,
+    val packageInfo: PackageInfo
+) : ViewModel() {
+
+    private val mUser = android.os.Process.myUserHandle()
+
+    /**
+     * Holds permission groups for a package or an empty map in case no user review is required.
+     */
+    val permissionGroupsLiveData =
+        object : SmartUpdateMediatorLiveData<Map<String, LightAppPermGroup>>() {
+            val packagePermsLiveData = PackagePermissionsLiveData[packageInfo.packageName, mUser]
+
+            init {
+                addSource(packagePermsLiveData) {
+                    update()
+                }
+            }
+
+            val permissionGroups = mutableMapOf<String, LightAppPermGroupLiveData>()
+
+            override fun onUpdate() {
+                val permissionGroupsMap = packagePermsLiveData.value ?: return
+                val filteredGroups = permissionGroupsMap.keys.stream()
+                    .filter { it -> !it.equals(NON_RUNTIME_NORMAL_PERMS) }
+                    .collect(Collectors.toList())
+
+                val getPermGroupLiveData = { permGroupName: String ->
+                    LightAppPermGroupLiveData[packageInfo.packageName, permGroupName, mUser]
+                }
+                setSourcesToDifference(filteredGroups, permissionGroups, getPermGroupLiveData)
+                if (permissionGroups.values.all { it.isInitialized } &&
+                    permissionGroups.values.all { !it.isStale }) {
+                    val permGroups: List<LightAppPermGroup?> = permissionGroups.values.map {
+                        it.value }
+                    val reviewGroups = permGroups.filterNotNull().filter {
+                        shouldShowPermission(it) &&
+                            Utils.OS_PKG == it.permGroupInfo.packageName
+                    }.associateBy {
+                        it.permGroupName
+                    }
+                    value = if (reviewGroups.any { it.value.isReviewRequired }) reviewGroups
+                    else emptyMap()
+                }
+            }
+        }
+
+    fun isInitialized(): Boolean {
+        return permissionGroupsLiveData.isInitialized
+    }
+
+    private fun shouldShowPermission(group: LightAppPermGroup): Boolean {
+        if (!(group.foreground.isGrantable || group.background.isGrantable)) {
+            return false
+        }
+        val isPlatformPermission = group.packageName == Utils.OS_PKG
+        // Show legacy permissions only if the user chose that.
+        return !(isPlatformPermission && !Utils.isModernPermissionGroup(group.permGroupName))
+    }
+
+    fun isPackageUpdated(): Boolean {
+        val permGroupsMap: Map<String, LightAppPermGroup> = permissionGroupsLiveData.value!!
+        return permGroupsMap.any { !it.value.isReviewRequired }
+    }
+
+    /**
+     * Update the summary of a permission group that has background permission.
+     * This does not apply to permission groups that are fixed by policy
+     */
+    fun getSummaryForPermGroupWithBackgroundPermission(
+        state: PermissionTarget
+    ): PermissionSummary {
+        if (state != PermissionTarget.PERMISSION_NONE) {
+            if (state.and(PermissionTarget.PERMISSION_BACKGROUND)
+                != PermissionTarget.PERMISSION_NONE.value) {
+                return SummaryMessage.ACCESS_ALWAYS.toPermSummary()
+            } else {
+                return SummaryMessage.ACCESS_ONLY_FOREGROUND.toPermSummary()
+            }
+        } else {
+            return SummaryMessage.ACCESS_NEVER.toPermSummary()
+        }
+    }
+
+    fun getSummaryForIndividuallyControlledPermGroup(
+        permGroup: LightAppPermGroup
+    ): PermissionSummary {
+        var revokedCount = 0
+        val lightPerms = permGroup.allPermissions.values.toList()
+        val permissionCount = lightPerms.size
+        for (i in 0 until permissionCount) {
+            if (!lightPerms[i].isGrantedIncludingAppOp) {
+                revokedCount++
+            }
+        }
+        return when (revokedCount) {
+            0 -> {
+                SummaryMessage.REVOKED_NONE.toPermSummary()
+            }
+            permissionCount -> {
+                SummaryMessage.REVOKED_ALL.toPermSummary()
+            }
+            else -> {
+                PermissionSummary(SummaryMessage.REVOKED_COUNT, false, revokedCount)
+            }
+        }
+    }
+
+    /**
+     * Show all individual permissions in this group in a new fragment.
+     */
+    fun showAllPermissions(fragment: Fragment, args: Bundle) {
+        val navController: NavController = NavHostFragment.findNavController(fragment)
+        navController.navigateSafe(R.id.app_to_all_perms, args)
+    }
+
+    enum class SummaryMessage {
+        NO_SUMMARY,
+        DISABLED_BY_ADMIN,
+        ENABLED_BY_ADMIN,
+        ENABLED_SYSTEM_FIXED,
+        ENFORCED_BY_POLICY,
+        ENABLED_BY_ADMIN_FOREGROUND_ONLY,
+        ENABLED_BY_POLICY_FOREGROUND_ONLY,
+        ENABLED_BY_ADMIN_BACKGROUND_ONLY,
+        ENABLED_BY_POLICY_BACKGROUND_ONLY,
+        DISABLED_BY_ADMIN_BACKGROUND_ONLY,
+        DISABLED_BY_POLICY_BACKGROUND_ONLY,
+        REVOKED_NONE,
+        REVOKED_ALL,
+        REVOKED_COUNT,
+        ACCESS_ALWAYS,
+        ACCESS_ONLY_FOREGROUND,
+        ACCESS_NEVER;
+
+        fun toPermSummary(): PermissionSummary {
+            return PermissionSummary(this, false)
+        }
+
+        fun toPermSummary(isEnterprise: Boolean): PermissionSummary {
+            return PermissionSummary(this, isEnterprise)
+        }
+    }
+
+    data class PermissionSummary(
+        val msg: SummaryMessage,
+        val isEnterprise: Boolean = false,
+        val revokeCount: Int = 0
+    )
+
+    fun getSummaryForFixedByPolicyPermissionGroup(
+        mState: PermissionTarget,
+        permGroup: LightAppPermGroup,
+        context: Context
+    ): PermissionSummary {
+        val admin = getAdmin(context, permGroup)
+        val hasAdmin = admin != null
+        if (permGroup.isSystemFixed) {
+            // Permission is fully controlled by the system and cannot be switched
+            return SummaryMessage.ENABLED_SYSTEM_FIXED.toPermSummary()
+        } else if (isForegroundDisabledByPolicy(permGroup)) {
+            // Permission is fully controlled by policy and cannot be switched
+            return if (hasAdmin) {
+                SummaryMessage.DISABLED_BY_ADMIN.toPermSummary()
+            } else {
+                // Disabled state will be displayed by switch, so no need to add text for that
+                SummaryMessage.ENFORCED_BY_POLICY.toPermSummary()
+            }
+        } else if (permGroup.isPolicyFullyFixed) {
+            // Permission is fully controlled by policy and cannot be switched
+            if (!permGroup.hasBackgroundGroup) {
+                return if (hasAdmin) {
+                    SummaryMessage.ENABLED_BY_ADMIN.toPermSummary()
+                } else {
+                    // Enabled state will be displayed by switch, so no need to add text for that
+                    SummaryMessage.ENFORCED_BY_POLICY.toPermSummary()
+                }
+            } else {
+                if (mState.and(PermissionTarget.PERMISSION_BACKGROUND) !=
+                    PermissionTarget.PERMISSION_NONE.value) {
+                    return if (hasAdmin) {
+                        SummaryMessage.ENABLED_BY_ADMIN.toPermSummary()
+                    } else {
+                        // Enabled state will be displayed by switch, so no need to add text for
+                        // that
+                        SummaryMessage.ENFORCED_BY_POLICY.toPermSummary()
+                    }
+                } else {
+                    return if (hasAdmin) {
+                        SummaryMessage.ENABLED_BY_ADMIN_FOREGROUND_ONLY.toPermSummary()
+                    } else {
+                        SummaryMessage.ENABLED_BY_POLICY_BACKGROUND_ONLY.toPermSummary()
+                    }
+                }
+            }
+        } else {
+            // Part of the permission group can still be switched
+            if (permGroup.background.isPolicyFixed) {
+                return if (mState.and(PermissionTarget.PERMISSION_BACKGROUND) !=
+                    PermissionTarget.PERMISSION_NONE.value) {
+                    if (hasAdmin) {
+                        SummaryMessage.ENABLED_BY_ADMIN_BACKGROUND_ONLY.toPermSummary(true)
+                    } else {
+                        SummaryMessage.ENABLED_BY_POLICY_BACKGROUND_ONLY.toPermSummary()
+                    }
+                } else {
+                    if (hasAdmin) {
+                        SummaryMessage.DISABLED_BY_ADMIN_BACKGROUND_ONLY.toPermSummary(true)
+                    } else {
+                        SummaryMessage.DISABLED_BY_POLICY_BACKGROUND_ONLY.toPermSummary()
+                    }
+                }
+            } else if (permGroup.foreground.isPolicyFixed) {
+                return if (hasAdmin) {
+                    SummaryMessage.ENABLED_BY_ADMIN_FOREGROUND_ONLY.toPermSummary(true)
+                } else {
+                    SummaryMessage.ENABLED_BY_POLICY_FOREGROUND_ONLY.toPermSummary()
+                }
+            }
+        }
+        return SummaryMessage.NO_SUMMARY.toPermSummary()
+    }
+
+    /**
+     * Is the foreground part of this group disabled. If the foreground is disabled, there is no
+     * need to possible grant background access.
+     *
+     * @return `true` iff the permissions of this group are fixed
+     */
+    private fun isForegroundDisabledByPolicy(mGroup: LightAppPermGroup): Boolean {
+        return mGroup.foreground.isPolicyFixed && !mGroup.isGranted
+    }
+
+    /**
+     * Whether policy is system fixed or fully fixed or foreground disabled
+     */
+    fun isFixedOrForegroundDisabled(mGroup: LightAppPermGroup): Boolean {
+        return mGroup.isSystemFixed || mGroup.isPolicyFullyFixed ||
+            isForegroundDisabledByPolicy(mGroup)
+    }
+
+    /**
+     * Get the app that acts as admin for this profile.
+     *
+     * @return The admin or `null` if there is no admin.
+     */
+    fun getAdmin(context: Context, mGroup: LightAppPermGroup): EnforcedAdmin? {
+        return RestrictedLockUtils.getProfileOrDeviceOwner(context, mGroup.userHandle)
+    }
+
+    enum class PermissionTarget(val value: Int) {
+        PERMISSION_NONE(0),
+        PERMISSION_FOREGROUND(1),
+        PERMISSION_BACKGROUND(2),
+        PERMISSION_BOTH(3);
+
+        infix fun and(other: PermissionTarget): Int {
+            return value and other.value
+        }
+
+        infix fun and(other: Int): Int {
+            return value and other
+        }
+
+        infix fun or(other: PermissionTarget): Int {
+            return value or other.value
+        }
+
+        companion object {
+            fun fromInt(value: Int) = values().first { it.value == value }
+        }
+    }
+}
+
+class ReviewPermissionViewModelFactory(
+    private val app: Application,
+    private val packageInfo: PackageInfo
+) : ViewModelProvider.Factory {
+    override fun <T : ViewModel> create(modelClass: Class<T>): T {
+        @Suppress("UNCHECKED_CAST")
+        return ReviewPermissionsViewModel(app, packageInfo = packageInfo) as T
+    }
+}
\ No newline at end of file
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/utils/Utils.java b/PermissionController/src/com/android/permissioncontroller/permission/utils/Utils.java
index 1c4fa2c..e3715f7 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/utils/Utils.java
+++ b/PermissionController/src/com/android/permissioncontroller/permission/utils/Utils.java
@@ -288,7 +288,7 @@
 
         if (SdkLevel.isAtLeastT()) {
             PLATFORM_PERMISSIONS.put(Manifest.permission.READ_MEDIA_AUDIO, READ_MEDIA_AURAL);
-            PLATFORM_PERMISSIONS.put(Manifest.permission.READ_MEDIA_IMAGE, READ_MEDIA_VISUAL);
+            PLATFORM_PERMISSIONS.put(Manifest.permission.READ_MEDIA_IMAGES, READ_MEDIA_VISUAL);
             PLATFORM_PERMISSIONS.put(Manifest.permission.READ_MEDIA_VIDEO, READ_MEDIA_VISUAL);
         }
 
diff --git a/PermissionController/src/com/android/permissioncontroller/role/model/HomeRoleBehavior.java b/PermissionController/src/com/android/permissioncontroller/role/model/HomeRoleBehavior.java
index 0cbc203..f10b804 100644
--- a/PermissionController/src/com/android/permissioncontroller/role/model/HomeRoleBehavior.java
+++ b/PermissionController/src/com/android/permissioncontroller/role/model/HomeRoleBehavior.java
@@ -24,6 +24,7 @@
 import android.content.Intent;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
+import android.content.pm.PermissionInfo;
 import android.content.pm.ResolveInfo;
 import android.os.Build;
 import android.os.UserHandle;
@@ -204,13 +205,41 @@
             Permissions.grant(packageName, AUTOMOTIVE_PERMISSIONS,
                     true, false, true, false, false, context);
         }
+
+        // Before T, ALLOW_SLIPPERY_TOUCHES may either not exist, or may not be a role permission
+        if (isRolePermission(android.Manifest.permission.ALLOW_SLIPPERY_TOUCHES, context)) {
+            Permissions.grant(packageName,
+                    Arrays.asList(android.Manifest.permission.ALLOW_SLIPPERY_TOUCHES),
+                    true, false, true, false, false, context);
+        }
     }
 
     @Override
-    public void revoke(@NonNull Role role, @NonNull String packageName,
-            @NonNull Context context) {
+    public void revoke(@NonNull Role role, @NonNull String packageName, @NonNull Context context) {
         if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) {
             Permissions.revoke(packageName, AUTOMOTIVE_PERMISSIONS, true, false, false, context);
         }
+
+        // Before T, ALLOW_SLIPPERY_TOUCHES may either not exist, or may not be a role permission
+        if (isRolePermission(android.Manifest.permission.ALLOW_SLIPPERY_TOUCHES, context)) {
+            Permissions.revoke(packageName,
+                    Arrays.asList(android.Manifest.permission.ALLOW_SLIPPERY_TOUCHES),
+                    true, false, false, context);
+        }
+    }
+
+    /**
+     * Return true if the permission exists, and has 'role' protection level.
+     * Return false otherwise.
+     */
+    private boolean isRolePermission(@NonNull String permissionName, @NonNull Context context) {
+        PermissionInfo permissionInfo;
+        try {
+            permissionInfo = context.getPackageManager().getPermissionInfo(permissionName, 0);
+        } catch (PackageManager.NameNotFoundException e) {
+            return false;
+        }
+        final int flags = permissionInfo.getProtectionFlags();
+        return (flags & PermissionInfo.PROTECTION_FLAG_ROLE) == PermissionInfo.PROTECTION_FLAG_ROLE;
     }
 }
diff --git a/PermissionController/src/com/android/permissioncontroller/safetycenter/ui/SafetyCenterActivity.java b/PermissionController/src/com/android/permissioncontroller/safetycenter/ui/SafetyCenterActivity.java
index 4b947aa..64c52d1 100644
--- a/PermissionController/src/com/android/permissioncontroller/safetycenter/ui/SafetyCenterActivity.java
+++ b/PermissionController/src/com/android/permissioncontroller/safetycenter/ui/SafetyCenterActivity.java
@@ -15,7 +15,13 @@
  */
 package com.android.permissioncontroller.safetycenter.ui;
 
+import static android.content.Intent.FLAG_ACTIVITY_FORWARD_RESULT;
+
+import android.content.Intent;
 import android.os.Bundle;
+import android.provider.Settings;
+import android.safetycenter.SafetyCenterManager;
+import android.util.Log;
 
 import androidx.annotation.Keep;
 
@@ -28,9 +34,21 @@
 @Keep
 public final class SafetyCenterActivity extends CollapsingToolbarBaseActivity {
 
+    private static final String TAG = SafetyCenterActivity.class.getSimpleName();
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
+        SafetyCenterManager safetyCenterManager = getSystemService(SafetyCenterManager.class);
+
+        if (safetyCenterManager == null || !safetyCenterManager.isSafetyCenterEnabled()) {
+            Log.w(TAG, "Safety Center disabled, redirecting to security settings page");
+            startActivity(new Intent(Settings.ACTION_SECURITY_SETTINGS).addFlags(
+                    FLAG_ACTIVITY_FORWARD_RESULT));
+            finish();
+            return;
+        }
+
         setTitle(getString(R.string.safety_center_dashboard_page_title));
         getSupportFragmentManager()
                 .beginTransaction()
diff --git a/PermissionController/tests/mocking/AndroidManifest.xml b/PermissionController/tests/mocking/AndroidManifest.xml
index f94470c..d4620c1 100644
--- a/PermissionController/tests/mocking/AndroidManifest.xml
+++ b/PermissionController/tests/mocking/AndroidManifest.xml
@@ -20,6 +20,8 @@
     xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.android.permissioncontroller.tests.mocking">
 
+    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
+
     <application android:label="PermissionController Mocking Tests"
         android:debuggable="true">
         <uses-library android:name="android.test.runner" />
diff --git a/PermissionController/tests/mocking/src/com/android/permissioncontroller/permission/service/NotificationListenerCheckTest.kt b/PermissionController/tests/mocking/src/com/android/permissioncontroller/permission/service/NotificationListenerCheckTest.kt
new file mode 100644
index 0000000..4a6573c
--- /dev/null
+++ b/PermissionController/tests/mocking/src/com/android/permissioncontroller/permission/service/NotificationListenerCheckTest.kt
@@ -0,0 +1,394 @@
+/*
+ * Copyright (C) 2022 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.permissioncontroller.permission.service
+
+import android.app.job.JobParameters
+import android.content.ComponentName
+import android.content.Context
+import android.provider.DeviceConfig
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.dx.mockito.inline.extended.ExtendedMockito
+import com.android.permissioncontroller.Constants
+import com.android.permissioncontroller.permission.service.NotificationListenerCheck.NlsComponent
+import com.android.permissioncontroller.permission.service.NotificationListenerCheck.NotificationListenerCheckJobService
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mock
+import org.mockito.Mockito.`when` as whenever
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+import org.mockito.MockitoSession
+import org.mockito.quality.Strictness
+
+/**
+ * Unit tests for internal [NotificationListenerCheck]
+ *
+ * <p> Does not test notification as there are conflicts with being able to mock NotifiationManager
+ * and PendintIntent.getBroadcast requiring a valid context. Notifications are tested in the CTS
+ * integration tests
+ */
+@RunWith(AndroidJUnit4::class)
+class NotificationListenerCheckTest {
+
+    @Mock
+    lateinit var notificationListenerCheckJobService: NotificationListenerCheckJobService
+
+    private lateinit var context: Context
+    private lateinit var mockitoSession: MockitoSession
+    private lateinit var notificationListenerCheck: NotificationListenerCheck
+
+    private var shouldCancel = false
+
+    @Before
+    fun setup() {
+        MockitoAnnotations.initMocks(this)
+        context = ApplicationProvider.getApplicationContext()
+
+        mockitoSession = ExtendedMockito.mockitoSession()
+            .mockStatic(DeviceConfig::class.java)
+            .strictness(Strictness.LENIENT).startMocking()
+
+        notificationListenerCheck = runWithShellPermissionIdentity {
+            NotificationListenerCheck(context) { shouldCancel }
+        }
+
+        enableNotificationListenerChecker(true)
+    }
+
+    @After
+    fun cleanup() {
+        // cleanup NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE
+        context.deleteFile(Constants.NOTIFICATION_LISTENER_CHECK_ALREADY_NOTIFIED_FILE)
+
+        shouldCancel = false
+        mockitoSession.finishMocking()
+    }
+
+    @Test
+    fun getEnabledNotificationListenersAndNotifyIfNeeded_featureDisabled_finishJob() {
+        enableNotificationListenerChecker(false)
+
+        val jobParameters = mock(JobParameters::class.java)
+        runWithShellPermissionIdentity {
+            runBlocking {
+                notificationListenerCheck.getEnabledNotificationListenersAndNotifyIfNeeded(
+                    jobParameters,
+                    notificationListenerCheckJobService
+                )
+            }
+        }
+
+        verify(notificationListenerCheckJobService).jobFinished(jobParameters, false)
+    }
+
+    @Test
+    fun getEnabledNotificationListenersAndNotifyIfNeeded_shouldCancel_finishJob_reschedule() {
+        shouldCancel = true
+        val jobParameters = mock(JobParameters::class.java)
+
+        runWithShellPermissionIdentity {
+            runBlocking {
+                notificationListenerCheck.getEnabledNotificationListenersAndNotifyIfNeeded(
+                    jobParameters,
+                    notificationListenerCheckJobService
+                )
+            }
+        }
+
+        verify(notificationListenerCheckJobService).jobFinished(jobParameters, true)
+    }
+
+    @Test
+    fun getEnabledNotificationListenersAndNotifyIfNeeded_finishJob() {
+        val jobParameters = mock(JobParameters::class.java)
+
+        runWithShellPermissionIdentity {
+            runBlocking {
+                notificationListenerCheck.getEnabledNotificationListenersAndNotifyIfNeeded(
+                    jobParameters,
+                    notificationListenerCheckJobService
+                )
+            }
+        }
+
+        verify(notificationListenerCheckJobService).jobFinished(jobParameters, false)
+    }
+
+    @Test
+    fun markAsNotified() {
+        var initialNlsComponents: Set<NlsComponent> = getNotifiedComponents()
+        assertThat(initialNlsComponents).isEmpty()
+
+        val testComponent = ComponentName("com.test.package", "TestClass")
+        val startTime = System.currentTimeMillis()
+
+        // Mark as notified, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val nlsComponent: NlsComponent = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent)
+            getNotifiedComponents()
+        }.filter { it.componentName == testComponent }
+            .also { assertThat(it.size).isEqualTo(1) }[0]
+
+        // Verify notified time is not zero, and at least the test start time
+        assertThat(nlsComponent.notificationShownTime).isNotEqualTo(0L)
+        assertThat(nlsComponent.notificationShownTime).isAtLeast(startTime)
+    }
+
+    @Test
+    fun markAsNotified_notifySecondComponent() {
+        var nlsComponents: Set<NlsComponent> = getNotifiedComponents()
+        assertThat(nlsComponents).isEmpty()
+
+        val testComponent = ComponentName("com.test.package", "TestClass")
+        val testComponent2 = ComponentName("com.test.package2", "TestClass2")
+
+        // Mark as notified, and get the resulting list of NlsComponents
+        nlsComponents = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent)
+            getNotifiedComponents()
+        }
+        // Expected # components is 1
+        assertThat(nlsComponents.size).isEqualTo(1)
+
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val firstComponent = nlsComponents
+            .filter { it.componentName == testComponent }
+            .also { assertThat(it.size).isEqualTo(1) }[0]
+
+        // Mark second component as notified, and get the resulting list of NlsComponents
+        nlsComponents = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent2)
+            getNotifiedComponents()
+        }
+        // Expected # components is 2
+        assertThat(nlsComponents.size).isEqualTo(2)
+
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val secondComponent = nlsComponents
+            .filter { it.componentName == testComponent2 }
+            .also { assertThat(it.size).isEqualTo(1) }[0]
+
+        // Ensure second component marked notified after first component
+        assertThat(secondComponent.notificationShownTime)
+            .isGreaterThan(firstComponent.notificationShownTime)
+    }
+
+    @Test
+    fun markAsNotified_notifySecondComponent_ensureFirstComponentNotModified() {
+        var nlsComponents: Set<NlsComponent> = getNotifiedComponents()
+        assertThat(nlsComponents).isEmpty()
+
+        val testComponent = ComponentName("com.test.package", "TestClass")
+        val testComponent2 = ComponentName("com.test.package2", "TestClass2")
+
+        // Mark as notified, and get the resulting list of NlsComponents
+        nlsComponents = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent)
+            getNotifiedComponents()
+        }
+        // Expected # components is 1
+        assertThat(nlsComponents.size).isEqualTo(1)
+
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val firstComponent = nlsComponents
+            .filter { it.componentName == testComponent }
+            .also { assertThat(it.size).isEqualTo(1) }[0]
+
+        // Mark second component as notified, and get the resulting list of NlsComponents
+        nlsComponents = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent2)
+            getNotifiedComponents()
+        }
+        // Expected # components is 2
+        assertThat(nlsComponents.size).isEqualTo(2)
+
+        // Verify first notified component still present
+        assertThat(nlsComponents.contains(firstComponent)).isTrue()
+    }
+
+    @Test
+    fun markAsNotifiedTwice_updatedNotificationTime() {
+        val testComponent = ComponentName("com.test.package", "TestClass")
+
+        // Mark as notified, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val initialNlsComponent: NlsComponent? = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent)
+            getNotifiedComponents()
+        }.filter { it.componentName == testComponent }
+            .also { assertThat(it.size).isEqualTo(1) }
+            .getOrNull(0)
+
+        assertThat(initialNlsComponent).isNotNull()
+
+        // Mark as notified *again*, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        // Ensure size is equal to one (not empty)
+        // Get the component
+        val updatedNlsComponent: NlsComponent? = runBlocking {
+            notificationListenerCheck.markAsNotified(testComponent)
+            getNotifiedComponents()
+        }.filter { it.componentName == testComponent }
+            .also { assertThat(it.size).isEqualTo(1) }
+            .getOrNull(0)
+
+        assertThat(updatedNlsComponent).isNotNull()
+
+        // Verify updated NlsComponent has an updated notificationShownTime
+        assertThat(updatedNlsComponent!!.notificationShownTime)
+            .isGreaterThan(initialNlsComponent!!.notificationShownTime)
+    }
+
+    @Test
+    fun removePackageState() {
+        val testComponent = ComponentName("com.test.package", "TestClass")
+        val testComponent2 = ComponentName("com.test.package2", "TestClass2")
+        val testComponents = listOf(testComponent, testComponent2)
+
+        // Mark all components as notified, and get the resulting list of NlsComponents
+        val initialNlsComponents = runBlocking {
+            testComponents.forEach {
+                notificationListenerCheck.markAsNotified(it)
+            }
+            getNotifiedComponents().map { it.componentName }
+        }
+
+        // Verify expected components are present
+        assertThat(initialNlsComponents).isNotNull()
+        assertThat(initialNlsComponents.size).isEqualTo(testComponents.size)
+        testComponents.forEach {
+            assertThat(initialNlsComponents.contains(it)).isTrue()
+        }
+
+        // Forget about test package, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        val updatedNlsComponents = runWithShellPermissionIdentity {
+            runBlocking {
+                notificationListenerCheck.removePackageState(testComponent.packageName)
+                getNotifiedComponents().map { it.componentName }
+            }
+        }
+
+        // Verify expected components are present
+        assertThat(updatedNlsComponents).isNotNull()
+        assertThat(updatedNlsComponents.size).isEqualTo(testComponents.size - 1)
+        assertThat(updatedNlsComponents.contains(testComponent)).isFalse()
+        assertThat(updatedNlsComponents.contains(testComponent2)).isTrue()
+    }
+
+    @Test
+    fun removePackageState_multipleNlsPerPackage() {
+        val testComponent = ComponentName("com.test.package", "TestClass")
+        val testComponent2 = ComponentName("com.test.package", "TestClass2")
+        val testComponents = listOf(testComponent, testComponent2)
+
+        // Mark all components as notified, and get the resulting list of NlsComponents
+        val initialNlsComponents = runBlocking {
+            testComponents.forEach {
+                notificationListenerCheck.markAsNotified(it)
+            }
+            getNotifiedComponents().map { it.componentName }
+        }
+
+        // Verify expected components are present
+        assertThat(initialNlsComponents).isNotNull()
+        assertThat(initialNlsComponents.size).isEqualTo(testComponents.size)
+        testComponents.forEach {
+            assertThat(initialNlsComponents.contains(it)).isTrue()
+        }
+
+        // Forget about test package, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        val updatedNlsComponents = runWithShellPermissionIdentity {
+            runBlocking {
+                notificationListenerCheck.removePackageState(testComponent.packageName)
+                getNotifiedComponents().map { it.componentName }
+            }
+        }
+
+        // Ensure empty
+        assertThat(updatedNlsComponents).isEmpty()
+    }
+
+    @Test
+    fun removePackageState_noPreviouslyNotifiedPackage() {
+        val testComponent = ComponentName("com.test.package", "TestClass")
+
+        // Get the initial list of Nls Components
+        val initialNlsComponents = getNotifiedComponents().map { it.componentName }
+
+        // Verify no components are present
+        assertThat(initialNlsComponents).isEmpty()
+
+        // Forget about test package, and get the resulting list of NlsComponents
+        // Filter to the component that match the test component
+        val updatedNlsComponents = runWithShellPermissionIdentity {
+            runBlocking {
+                // Verify this should not fail!
+                notificationListenerCheck.removePackageState(testComponent.packageName)
+                getNotifiedComponents().map { it.componentName }
+            }
+        }
+
+        // Verify no components are present
+        assertThat(updatedNlsComponents).isEmpty()
+    }
+
+    private fun enableNotificationListenerChecker(enabled: Boolean) {
+        whenever(
+            DeviceConfig.getBoolean(
+                eq(DeviceConfig.NAMESPACE_PRIVACY),
+                eq(PROPERTY_NOTIFICATION_LISTENER_CHECK_ENABLED),
+                anyBoolean()
+            )
+        ).thenReturn(enabled)
+    }
+
+    private fun getNotifiedComponents(): Set<NlsComponent> = runBlocking {
+        notificationListenerCheck.loadNotifiedComponentsLocked()
+    }
+
+    private fun <R> runWithShellPermissionIdentity(block: () -> R): R {
+        val uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation()
+        uiAutomation.adoptShellPermissionIdentity()
+        try {
+            return block()
+        } finally {
+            uiAutomation.dropShellPermissionIdentity()
+        }
+    }
+}
\ No newline at end of file
diff --git a/SafetyCenter/Resources/res/values/strings.xml b/SafetyCenter/Resources/res/values/strings.xml
index 594d20d..19a2cca 100644
--- a/SafetyCenter/Resources/res/values/strings.xml
+++ b/SafetyCenter/Resources/res/values/strings.xml
@@ -21,6 +21,10 @@
          the list of system apps. [CHAR LIMIT=40] -->
     <string name="safetyCenterResourcesAppLabel">Safety Center Resources</string>
 
-    <!-- Temporary reference -->
-    <string name="reference" translatable="false">Reference</string>
+    <!-- Lock Screen -->
+    <string name="lock_screen_sources_title" description="The title of the group of safety settings relating to screen lock and biometrics">Screen lock</string>
+    <string name="lock_screen_title" description="The default title of the setting for managing the screen lock on the device">Screen lock</string>
+    <string name="lock_screen_summary_disabled" description="The default summary of the setting for managing screen lock on the device before the current state is known to safety center">Details available soon</string>
+
+    <string name="summary_placeholder" description="DO NOT TRANSLATE" translatable="false">&#160;</string>
 </resources>
diff --git a/SafetyCenter/Resources/res/xml/safety_center_config.xml b/SafetyCenter/Resources/res/xml/safety_center_config.xml
index 1ea8282..04374b6 100644
--- a/SafetyCenter/Resources/res/xml/safety_center_config.xml
+++ b/SafetyCenter/Resources/res/xml/safety_center_config.xml
@@ -18,15 +18,23 @@
     <safety-sources-config>
         <!-- TODO(b/214567659): Finalize base XML config -->
         <safety-sources-group
-            id="TODO"
-            title="@string/reference"
-            summary="@string/reference">
-            <static-safety-source
-                id="TODO"
-                title="@string/reference"
-                summary="@string/reference"
-                intentAction="intent"
-                profile="primary_profile_only"/>
+            id="LockScreenSources"
+            title="@string/lock_screen_sources_title"
+            summary="@string/summary_placeholder">
+            <dynamic-safety-source
+                profile="primary_profile_only"
+                title="@string/lock_screen_title"
+                summary="@string/lock_screen_summary_disabled"
+                initialDisplayState="disabled"
+                packageName="com.android.settings"
+                broadcastReceiverClassName="com.android.settings.safetycenter.SafetySourceBroadcastReceiver"
+                id="LockScreen"/>
+            <dynamic-safety-source
+                profile="primary_profile_only"
+                packageName="com.android.settings"
+                initialDisplayState="hidden"
+                broadcastReceiverClassName="com.android.settings.safetycenter.SafetySourceBroadcastReceiver"
+                id="Biometrics"/>
         </safety-sources-group>
     </safety-sources-config>
 </safety-center-config>
diff --git a/framework-s/api/system-current.txt b/framework-s/api/system-current.txt
index 7f301c1..67c2b85 100644
--- a/framework-s/api/system-current.txt
+++ b/framework-s/api/system-current.txt
@@ -138,12 +138,12 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetyCenterEntryOrGroup> CREATOR;
   }
 
-  public final class SafetyCenterError implements android.os.Parcelable {
-    ctor public SafetyCenterError(@NonNull CharSequence);
+  public final class SafetyCenterErrorDetails implements android.os.Parcelable {
+    ctor public SafetyCenterErrorDetails(@NonNull CharSequence);
     method public int describeContents();
     method @NonNull public CharSequence getErrorMessage();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetyCenterError> CREATOR;
+    field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetyCenterErrorDetails> CREATOR;
   }
 
   public final class SafetyCenterIssue implements android.os.Parcelable {
@@ -203,14 +203,14 @@
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void addOnSafetyCenterDataChangedListener(@NonNull java.util.concurrent.Executor, @NonNull android.safetycenter.SafetyCenterManager.OnSafetyCenterDataChangedListener);
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void clearAllSafetySourceData();
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void clearSafetyCenterConfigOverride();
-    method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void dismissSafetyIssue(@NonNull String);
-    method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void executeAction(@NonNull String, @NonNull String);
+    method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void dismissSafetyCenterIssue(@NonNull String);
+    method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void executeSafetyCenterIssueAction(@NonNull String, @NonNull String);
     method @NonNull @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public android.safetycenter.SafetyCenterData getSafetyCenterData();
     method @Nullable @RequiresPermission(android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE) public android.safetycenter.SafetySourceData getSafetySourceData(@NonNull String);
     method @RequiresPermission(anyOf={android.Manifest.permission.READ_SAFETY_CENTER_STATUS, android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE}) public boolean isSafetyCenterEnabled();
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void refreshSafetySources(int);
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void removeOnSafetyCenterDataChangedListener(@NonNull android.safetycenter.SafetyCenterManager.OnSafetyCenterDataChangedListener);
-    method @RequiresPermission(android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE) public void reportSafetySourceError(@NonNull String, @NonNull android.safetycenter.SafetySourceError);
+    method @RequiresPermission(android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE) public void reportSafetySourceError(@NonNull String, @NonNull android.safetycenter.SafetySourceErrorDetails);
     method @RequiresPermission(android.Manifest.permission.MANAGE_SAFETY_CENTER) public void setSafetyCenterConfigOverride(@NonNull android.safetycenter.config.SafetyCenterConfig);
     method @RequiresPermission(android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE) public void setSafetySourceData(@NonNull String, @Nullable android.safetycenter.SafetySourceData, @NonNull android.safetycenter.SafetyEvent);
     field public static final String ACTION_REFRESH_SAFETY_SOURCES = "android.safetycenter.action.REFRESH_SAFETY_SOURCES";
@@ -219,12 +219,16 @@
     field public static final String EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID = "android.safetycenter.extra.REFRESH_SAFETY_SOURCES_BROADCAST_ID";
     field public static final String EXTRA_REFRESH_SAFETY_SOURCES_REQUEST_TYPE = "android.safetycenter.extra.REFRESH_SAFETY_SOURCES_REQUEST_TYPE";
     field public static final String EXTRA_REFRESH_SAFETY_SOURCE_IDS = "android.safetycenter.extra.REFRESH_SAFETY_SOURCE_IDS";
+    field public static final int REFRESH_REASON_DEVICE_LOCALE_CHANGE = 400; // 0x190
+    field public static final int REFRESH_REASON_DEVICE_REBOOT = 300; // 0x12c
+    field public static final int REFRESH_REASON_OTHER = 600; // 0x258
     field public static final int REFRESH_REASON_PAGE_OPEN = 100; // 0x64
     field public static final int REFRESH_REASON_RESCAN_BUTTON_CLICK = 200; // 0xc8
+    field public static final int REFRESH_REASON_SAFETY_CENTER_ENABLED = 500; // 0x1f4
   }
 
   public static interface SafetyCenterManager.OnSafetyCenterDataChangedListener {
-    method public default void onError(@NonNull android.safetycenter.SafetyCenterError);
+    method public default void onError(@NonNull android.safetycenter.SafetyCenterErrorDetails);
     method public void onSafetyCenterDataChanged(@NonNull android.safetycenter.SafetyCenterData);
   }
 
@@ -277,13 +281,13 @@
   public final class SafetyEvent implements android.os.Parcelable {
     method public int describeContents();
     method @Nullable public String getRefreshBroadcastId();
-    method public int getSafetyEventType();
     method @Nullable public String getSafetySourceIssueActionId();
     method @Nullable public String getSafetySourceIssueId();
+    method public int getType();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetyEvent> CREATOR;
     field public static final int SAFETY_EVENT_TYPE_DEVICE_LOCALE_CHANGED = 500; // 0x1f4
-    field public static final int SAFETY_EVENT_TYPE_DEVICE_REBOOTED = 500; // 0x1f4
+    field public static final int SAFETY_EVENT_TYPE_DEVICE_REBOOTED = 600; // 0x258
     field public static final int SAFETY_EVENT_TYPE_REFRESH_REQUESTED = 200; // 0xc8
     field public static final int SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED = 400; // 0x190
     field public static final int SAFETY_EVENT_TYPE_RESOLVING_ACTION_SUCCEEDED = 300; // 0x12c
@@ -314,19 +318,19 @@
     method @NonNull public android.safetycenter.SafetySourceData.Builder setStatus(@Nullable android.safetycenter.SafetySourceStatus);
   }
 
-  public final class SafetySourceError implements android.os.Parcelable {
-    ctor public SafetySourceError(@NonNull android.safetycenter.SafetyEvent);
+  public final class SafetySourceErrorDetails implements android.os.Parcelable {
+    ctor public SafetySourceErrorDetails(@NonNull android.safetycenter.SafetyEvent);
     method public int describeContents();
     method @NonNull public android.safetycenter.SafetyEvent getSafetyEvent();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetySourceError> CREATOR;
+    field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetySourceErrorDetails> CREATOR;
   }
 
   public final class SafetySourceIssue implements android.os.Parcelable {
     method public int describeContents();
     method @NonNull public java.util.List<android.safetycenter.SafetySourceIssue.Action> getActions();
     method @NonNull public String getId();
-    method @NonNull public String getIssueCategory();
+    method public int getIssueCategory();
     method @NonNull public String getIssueTypeId();
     method @Nullable public android.app.PendingIntent getOnDismissPendingIntent();
     method public int getSeverityLevel();
@@ -335,9 +339,9 @@
     method @NonNull public CharSequence getTitle();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetySourceIssue> CREATOR;
-    field public static final String ISSUE_CATEGORY_ACCOUNT = "issue_category_account";
-    field public static final String ISSUE_CATEGORY_DEVICE = "issue_category_device";
-    field public static final String ISSUE_CATEGORY_GENERAL = "issue_category_general";
+    field public static final int ISSUE_CATEGORY_ACCOUNT = 200; // 0xc8
+    field public static final int ISSUE_CATEGORY_DEVICE = 100; // 0x64
+    field public static final int ISSUE_CATEGORY_GENERAL = 300; // 0x12c
     field public static final int SEVERITY_LEVEL_CRITICAL_WARNING = 400; // 0x190
     field public static final int SEVERITY_LEVEL_INFORMATION = 200; // 0xc8
     field public static final int SEVERITY_LEVEL_RECOMMENDATION = 300; // 0x12c
@@ -349,7 +353,7 @@
     method @NonNull public CharSequence getLabel();
     method @NonNull public android.app.PendingIntent getPendingIntent();
     method @Nullable public CharSequence getSuccessMessage();
-    method public boolean isResolving();
+    method public boolean willResolve();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetySourceIssue.Action> CREATOR;
   }
@@ -357,8 +361,8 @@
   public static final class SafetySourceIssue.Action.Builder {
     ctor public SafetySourceIssue.Action.Builder(@NonNull String, @NonNull CharSequence, @NonNull android.app.PendingIntent);
     method @NonNull public android.safetycenter.SafetySourceIssue.Action build();
-    method @NonNull public android.safetycenter.SafetySourceIssue.Action.Builder setResolving(boolean);
     method @NonNull public android.safetycenter.SafetySourceIssue.Action.Builder setSuccessMessage(@Nullable CharSequence);
+    method @NonNull public android.safetycenter.SafetySourceIssue.Action.Builder setWillResolve(boolean);
   }
 
   public static final class SafetySourceIssue.Builder {
@@ -366,7 +370,7 @@
     method @NonNull public android.safetycenter.SafetySourceIssue.Builder addAction(@NonNull android.safetycenter.SafetySourceIssue.Action);
     method @NonNull public android.safetycenter.SafetySourceIssue build();
     method @NonNull public android.safetycenter.SafetySourceIssue.Builder clearActions();
-    method @NonNull public android.safetycenter.SafetySourceIssue.Builder setIssueCategory(@NonNull String);
+    method @NonNull public android.safetycenter.SafetySourceIssue.Builder setIssueCategory(int);
     method @NonNull public android.safetycenter.SafetySourceIssue.Builder setOnDismissPendingIntent(@Nullable android.app.PendingIntent);
     method @NonNull public android.safetycenter.SafetySourceIssue.Builder setSubtitle(@Nullable CharSequence);
   }
@@ -395,14 +399,14 @@
   }
 
   public static final class SafetySourceStatus.IconAction implements android.os.Parcelable {
-    ctor public SafetySourceStatus.IconAction(@NonNull String, @NonNull android.app.PendingIntent);
+    ctor public SafetySourceStatus.IconAction(int, @NonNull android.app.PendingIntent);
     method public int describeContents();
-    method @NonNull public String getIconType();
+    method public int getIconType();
     method @NonNull public android.app.PendingIntent getPendingIntent();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.safetycenter.SafetySourceStatus.IconAction> CREATOR;
-    field public static final String ICON_TYPE_GEAR = "icon_type_gear";
-    field public static final String ICON_TYPE_INFO = "icon_type_info";
+    field public static final int ICON_TYPE_GEAR = 100; // 0x64
+    field public static final int ICON_TYPE_INFO = 200; // 0xc8
   }
 
 }
diff --git a/framework-s/java/android/safetycenter/IOnSafetyCenterDataChangedListener.aidl b/framework-s/java/android/safetycenter/IOnSafetyCenterDataChangedListener.aidl
index 37ab9c6..cee97ea 100644
--- a/framework-s/java/android/safetycenter/IOnSafetyCenterDataChangedListener.aidl
+++ b/framework-s/java/android/safetycenter/IOnSafetyCenterDataChangedListener.aidl
@@ -17,7 +17,7 @@
 package android.safetycenter;
 
 import android.safetycenter.SafetyCenterData;
-import android.safetycenter.SafetyCenterError;
+import android.safetycenter.SafetyCenterErrorDetails;
 
 /**
  * Listener for changes to {@link SafetyCenterData}.
@@ -30,5 +30,5 @@
     void onSafetyCenterDataChanged(in SafetyCenterData data);
 
     /** Called when SafetyCenter should display an error related to changes in its data. */
-    void onError(in SafetyCenterError error);
+    void onError(in SafetyCenterErrorDetails safetyCenterErrorDetails);
  }
diff --git a/framework-s/java/android/safetycenter/ISafetyCenterManager.aidl b/framework-s/java/android/safetycenter/ISafetyCenterManager.aidl
index 6c67c48..902b9f6 100644
--- a/framework-s/java/android/safetycenter/ISafetyCenterManager.aidl
+++ b/framework-s/java/android/safetycenter/ISafetyCenterManager.aidl
@@ -18,26 +18,26 @@
 
 import android.safetycenter.IOnSafetyCenterDataChangedListener;
 import android.safetycenter.SafetyCenterData;
-import android.safetycenter.SafetyCenterError;
 import android.safetycenter.SafetyEvent;
 import android.safetycenter.SafetySourceData;
-import android.safetycenter.SafetySourceError;
+import android.safetycenter.SafetySourceErrorDetails;
 import android.safetycenter.config.SafetyCenterConfig;
 
 /**
- * AIDL service for the safety center.
+ * AIDL Interface for communicating with the Safety Center, which consolidates UI for security and
+ * privacy features on the device.
  *
- * This is the main entry point for gathering data from various safety sources. Safety sources can
- * call this service to provide new data to the safety center. This data will be aggregated and
- * merged into a single safety status, warning cards and settings preferences visible in the safety
- * center.
+ * These APIs are intended to be used by the following clients:
+ * <ul>
+ *     <li>Safety sources represented in Safety Center UI
+ *     <li>Dependents on the state of Safety Center UI
+ *     <li>Managers of Safety Center UI
+ * </ul>
  *
  * @hide
  */
 interface ISafetyCenterManager {
-    /**
-     * Returns whether the SafetyCenter page is enabled.
-     */
+    /** Returns whether the Safety Center feature is enabled. */
     boolean isSafetyCenterEnabled();
 
     /**
@@ -63,12 +63,13 @@
      * <p>Safety sources should use this API to notify SafetyCenter when SafetyCenter requested or
      * expected them to perform an action or provide data, but they were unable to do so.
      */
-    void reportSafetySourceError(String safetySourceId,
-            in SafetySourceError error,
+    void reportSafetySourceError(
+            String safetySourceId,
+            in SafetySourceErrorDetails safetySourceErrorDetails,
             String packageName,
             int userId);
 
-    /** Requests safety sources to send their latest SafetySourceData to Safety Center. */
+    /** Requests safety sources to set their latest SafetySourceData for Safety Center. */
     void refreshSafetySources(int refreshReason, int userId);
 
     /**
@@ -85,12 +86,16 @@
             int userId);
 
     /**
-     * Dismisses the issue corresponding to the given issue ID.
+     * Dismiss a Safety Center issue and prevent it from appearing in the Safety Center or affecting
+     * the overall safety status.
      */
-    void dismissSafetyIssue(String issueId, int userId);
+    void dismissSafetyCenterIssue(String issueId, int userId);
 
-    /** Executes the specified action on the specified issue. */
-    void executeAction(String safetyCenterIssueId, String safetyCenterActionId, int userId);
+    /** Executes the specified Safety Center issue action on the specified Safety Center issue. */
+    void executeSafetyCenterIssueAction(
+            String safetyCenterIssueId,
+            String safetyCenterIssueActionId,
+            int userId);
 
     /**
      * Clears all SafetySourceData set by safety sources using setSafetySourceData.
diff --git a/framework-s/java/android/safetycenter/SafetyCenterError.aidl b/framework-s/java/android/safetycenter/SafetyCenterErrorDetails.aidl
similarity index 88%
copy from framework-s/java/android/safetycenter/SafetyCenterError.aidl
copy to framework-s/java/android/safetycenter/SafetyCenterErrorDetails.aidl
index 032dd8a..269ed3e 100644
--- a/framework-s/java/android/safetycenter/SafetyCenterError.aidl
+++ b/framework-s/java/android/safetycenter/SafetyCenterErrorDetails.aidl
@@ -17,8 +17,8 @@
 package android.safetycenter;
 
 /**
- * Parcelable AIDL SafetyCenterError.
+ * Parcelable AIDL SafetyCenterErrorDetails.
  *
  * @hide
  */
-parcelable SafetyCenterError;
\ No newline at end of file
+parcelable SafetyCenterErrorDetails;
\ No newline at end of file
diff --git a/framework-s/java/android/safetycenter/SafetyCenterError.java b/framework-s/java/android/safetycenter/SafetyCenterErrorDetails.java
similarity index 70%
rename from framework-s/java/android/safetycenter/SafetyCenterError.java
rename to framework-s/java/android/safetycenter/SafetyCenterErrorDetails.java
index fe8409b..99dbc01 100644
--- a/framework-s/java/android/safetycenter/SafetyCenterError.java
+++ b/framework-s/java/android/safetycenter/SafetyCenterErrorDetails.java
@@ -29,19 +29,19 @@
 import java.util.Objects;
 
 /**
- * An error the Safety Center should display to the user.
+ * Details of an error that the Safety Center should display to the user.
  *
  * @hide
  */
 @SystemApi
 @RequiresApi(TIRAMISU)
-public final class SafetyCenterError implements Parcelable {
+public final class SafetyCenterErrorDetails implements Parcelable {
 
     @NonNull
     private final CharSequence mErrorMessage;
 
-    /** Creates a {@link SafetyCenterError} with a given error message. */
-    public SafetyCenterError(@NonNull CharSequence errorMessage) {
+    /** Creates a {@link SafetyCenterErrorDetails} with a given error message. */
+    public SafetyCenterErrorDetails(@NonNull CharSequence errorMessage) {
         mErrorMessage = errorMessage;
     }
 
@@ -55,7 +55,7 @@
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
-        SafetyCenterError that = (SafetyCenterError) o;
+        SafetyCenterErrorDetails that = (SafetyCenterErrorDetails) o;
         return TextUtils.equals(mErrorMessage, that.mErrorMessage);
     }
 
@@ -66,7 +66,7 @@
 
     @Override
     public String toString() {
-        return "SafetyCenterError{"
+        return "SafetyCenterErrorDetails{"
                 + "mErrorMessage=" + mErrorMessage
                 + '}';
     }
@@ -82,15 +82,17 @@
     }
 
     @NonNull
-    public static final Creator<SafetyCenterError> CREATOR = new Creator<SafetyCenterError>() {
+    public static final Creator<SafetyCenterErrorDetails> CREATOR =
+            new Creator<SafetyCenterErrorDetails>() {
         @Override
-        public SafetyCenterError createFromParcel(Parcel in) {
-            return new SafetyCenterError(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in));
+        public SafetyCenterErrorDetails createFromParcel(Parcel in) {
+            return new SafetyCenterErrorDetails(
+                    TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in));
         }
 
         @Override
-        public SafetyCenterError[] newArray(int size) {
-            return new SafetyCenterError[0];
+        public SafetyCenterErrorDetails[] newArray(int size) {
+            return new SafetyCenterErrorDetails[0];
         }
     };
 }
diff --git a/framework-s/java/android/safetycenter/SafetyCenterManager.java b/framework-s/java/android/safetycenter/SafetyCenterManager.java
index 3795d48..11fc874 100644
--- a/framework-s/java/android/safetycenter/SafetyCenterManager.java
+++ b/framework-s/java/android/safetycenter/SafetyCenterManager.java
@@ -33,6 +33,7 @@
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
 import android.content.Context;
+import android.os.Binder;
 import android.os.RemoteException;
 import android.safetycenter.config.SafetyCenterConfig;
 import android.util.ArrayMap;
@@ -47,7 +48,15 @@
 import java.util.concurrent.Executor;
 
 /**
- * Interface for communicating with the safety center.
+ * Interface for communicating with the Safety Center, which consolidates UI for security and
+ * privacy features on the device.
+ *
+ * These APIs are intended to be used by the following clients:
+ * <ul>
+ *     <li>Safety sources represented in Safety Center UI
+ *     <li>Dependents on the state of Safety Center UI
+ *     <li>Managers of Safety Center UI
+ * </ul>
  *
  * @hide
  */
@@ -65,13 +74,19 @@
      * updated.
      *
      * <p>This broadcast is sent explicitly to safety sources by targeting intents to a specified
-     * set of components provided by the safety sources in the safety source configuration.
+     * set of components provided by the safety sources in the {@link SafetyCenterConfig}.
      * The receiving components should be manifest-declared receivers so that safety sources can be
      * requested to send data even if they are not running.
      *
      * <p>On receiving this broadcast, safety sources should determine their safety state according
-     * to the parameters specified in the intent extras (see below) and send Safety Center data
-     * about their safety state using {@link #setSafetySourceData}.
+     * to the parameters specified in the intent extras (see below) and set {@link SafetySourceData}
+     * using {@link #setSafetySourceData}, along with a {@link SafetyEvent} with
+     * {@link SafetyEvent#getType()} set to {@link SafetyEvent#SAFETY_EVENT_TYPE_REFRESH_REQUESTED}
+     * and {@link SafetyEvent#getRefreshBroadcastId()} set to the value of broadcast intent extra
+     * {@link #EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID}. If the safety source is unable to provide
+     * data, it can set a {@code null} {@link SafetySourceData}, which will clear any existing
+     * {@link SafetySourceData} stored by Safety Center, and Safety Center will fall back to any
+     * placeholder data specified in {@link SafetyCenterConfig}.
      *
      * <p class="note">This is a protected intent that can only be sent by the system.
      *
@@ -84,12 +99,12 @@
      * safety sources being requested for data. This extra exists for disambiguation in the case
      * that a single component is responsible for receiving refresh requests for multiple safety
      * sources.
+     * <li>{@link #EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID}: An unique identifier for the refresh
+     * request broadcast. This extra should be used to specify
+     * {@link SafetyEvent#getRefreshBroadcastId()} when the safety source responds to the broadcast
+     * using {@link #setSafetySourceData}.
      * </ul>
      */
-    // TODO(b/210805082): Define the term "safety sources" more concretely here once safety sources
-    //  are configured in xml config.
-    // TODO(b/210979035): Determine recommendation for sources if they are requested for fresh data
-    //  but cannot provide it.
     @SdkConstant(BROADCAST_INTENT_ACTION)
     public static final String ACTION_REFRESH_SAFETY_SOURCES =
             "android.safetycenter.action.REFRESH_SAFETY_SOURCES";
@@ -118,7 +133,6 @@
     /**
      * Used as an {@code String} extra field in {@link #ACTION_REFRESH_SAFETY_SOURCES} intents to
      * specify a string identifier for the broadcast.
-     *
      */
     public static final String EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID =
             "android.safetycenter.extra.REFRESH_SAFETY_SOURCES_BROADCAST_ID";
@@ -168,6 +182,10 @@
     @IntDef(prefix = {"REFRESH_REASON_"}, value = {
             REFRESH_REASON_PAGE_OPEN,
             REFRESH_REASON_RESCAN_BUTTON_CLICK,
+            REFRESH_REASON_DEVICE_REBOOT,
+            REFRESH_REASON_DEVICE_LOCALE_CHANGE,
+            REFRESH_REASON_SAFETY_CENTER_ENABLED,
+            REFRESH_REASON_OTHER
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface RefreshReason {
@@ -179,6 +197,18 @@
     /** Indicates that the rescan button in the Safety Center UI has been clicked on by the user. */
     public static final int REFRESH_REASON_RESCAN_BUTTON_CLICK = 200;
 
+    /** Indicates that the device was rebooted. */
+    public static final int REFRESH_REASON_DEVICE_REBOOT = 300;
+
+    /** Indicates that the device locale was changed. */
+    public static final int REFRESH_REASON_DEVICE_LOCALE_CHANGE = 400;
+
+    /** Indicates that the Safety Center feature was enabled. */
+    public static final int REFRESH_REASON_SAFETY_CENTER_ENABLED = 500;
+
+    /** Indicates a generic reason for Safety Center refresh. */
+    public static final int REFRESH_REASON_OTHER = 600;
+
     /** Listener for changes to {@link SafetyCenterData}. */
     public interface OnSafetyCenterDataChangedListener {
 
@@ -192,9 +222,10 @@
         /**
          * Called when the Safety Center should display an error related to changes in its data.
          *
-         * @param error an error that should be displayed to the user
+         * @param errorDetails details of an error that should be displayed to the user
          */
-        default void onError(@NonNull SafetyCenterError error) {}
+        default void onError(@NonNull SafetyCenterErrorDetails errorDetails) {
+        }
     }
 
     private final Object mListenersLock = new Object();
@@ -219,7 +250,10 @@
     }
 
     /**
-     * Returns whether the SafetyCenter page is enabled.
+     * Returns whether the Safety Center feature is enabled.
+     *
+     * <p>If this returns {@code false}, all the other methods in this class will no-op and/or
+     * return default values.
      */
     @RequiresPermission(anyOf = {
             READ_SAFETY_CENTER_STATUS,
@@ -235,7 +269,7 @@
 
     /**
      * Set the latest {@link SafetySourceData} for a safety source, to be displayed in
-     * SafetyCenter UI.
+     * Safety Center UI.
      *
      * <p>Each {@code safetySourceId} uniquely identifies the {@link SafetySourceData} for the
      * calling user.
@@ -243,18 +277,22 @@
      * <p>This call will rewrite any existing {@link SafetySourceData} already set for the given
      * {@code safetySourceId} for the calling user.
      *
-     * @param safetySourceId the unique identifier for a safety source in the calling user
+     * @param safetySourceId   the unique identifier for a safety source in the calling user
      * @param safetySourceData the latest safety data for the safety source in the calling user. If
-     *                        a safety source does not have any data to set, it can set its
-     *                        {@link SafetySourceData} to {@code null}, in which case Safety Center
-     *                        will fall back to any placeholder data specified in the safety source
-     *                        xml configuration.
-     * @param safetyEvent the event that triggered the safety source to set safety data
+     *                         a safety source does not have any data to set, it can set its
+     *                         {@link SafetySourceData} to {@code null}, in which case Safety Center
+     *                         will fall back to any placeholder data specified in the safety source
+     *                         xml configuration.
+     * @param safetyEvent      the event that triggered the safety source to set safety data
      */
     @RequiresPermission(SEND_SAFETY_CENTER_UPDATE)
-    public void setSafetySourceData(@NonNull String safetySourceId,
+    public void setSafetySourceData(
+            @NonNull String safetySourceId,
             @Nullable SafetySourceData safetySourceData,
             @NonNull SafetyEvent safetyEvent) {
+        requireNonNull(safetySourceId, "safetySourceId cannot be null");
+        requireNonNull(safetyEvent, "safetyEvent cannot be null");
+
         try {
             mService.setSafetySourceData(
                     safetySourceId,
@@ -278,6 +316,7 @@
     @Nullable
     public SafetySourceData getSafetySourceData(@NonNull String safetySourceId) {
         requireNonNull(safetySourceId, "safetySourceId cannot be null");
+
         try {
             return mService.getSafetySourceData(
                     safetySourceId,
@@ -289,21 +328,25 @@
     }
 
     /**
-     * Notifies the SafetyCenter of an error related to a given safety source.
+     * Notifies the Safety Center of an error related to a given safety source.
      *
-     * <p>Safety sources should use this API to notify SafetyCenter when SafetyCenter requested or
+     * <p>Safety sources should use this API to notify Safety Center when Safety Center requested or
      * expected them to perform an action or provide data, but they were unable to do so.
      *
-     * @param safetySourceId the id of the safety source that provided the issue
-     * @param error the error that occurred
+     * @param safetySourceId           the id of the safety source that provided the issue
+     * @param safetySourceErrorDetails details of the error that occurred
      */
     @RequiresPermission(SEND_SAFETY_CENTER_UPDATE)
     public void reportSafetySourceError(
-            @NonNull String safetySourceId, @NonNull SafetySourceError error) {
+            @NonNull String safetySourceId,
+            @NonNull SafetySourceErrorDetails safetySourceErrorDetails) {
+        requireNonNull(safetySourceId, "safetySourceId cannot be null");
+        requireNonNull(safetySourceErrorDetails, "safetySourceErrorDetails cannot be null");
+
         try {
             mService.reportSafetySourceError(
                     safetySourceId,
-                    error,
+                    safetySourceErrorDetails,
                     mContext.getPackageName(),
                     mContext.getUser().getIdentifier());
         } catch (RemoteException e) {
@@ -312,15 +355,14 @@
     }
 
     /**
-     * Requests safety sources to send their latest {@link SafetySourceData} to Safety Center.
+     * Requests safety sources to set their latest {@link SafetySourceData} for Safety Center.
      *
      * <p>This API sends a broadcast to all safety sources with action
      * {@link #ACTION_REFRESH_SAFETY_SOURCES}.
      * See {@link #ACTION_REFRESH_SAFETY_SOURCES} for details on how safety sources should respond
      * to receiving these broadcasts.
      *
-     * @param refreshReason the reason for the refresh, either {@link #REFRESH_REASON_PAGE_OPEN} or
-     *                      {@link #REFRESH_REASON_RESCAN_BUTTON_CLICK}
+     * @param refreshReason the reason for the refresh
      */
     @RequiresPermission(MANAGE_SAFETY_CENTER)
     public void refreshSafetySources(@RefreshReason int refreshReason) {
@@ -396,35 +438,42 @@
     }
 
     /**
-     * Dismiss an active safety issue and prevent it from appearing in the Safety Center or
-     * affecting the overall safety status.
+     * Dismiss a Safety Center issue and prevent it from appearing in the Safety Center or affecting
+     * the overall safety status.
      *
      * @param safetyCenterIssueId the target issue ID returned by {@link SafetyCenterIssue#getId()}
      */
     @RequiresPermission(MANAGE_SAFETY_CENTER)
-    public void dismissSafetyIssue(@NonNull String safetyCenterIssueId) {
+    public void dismissSafetyCenterIssue(@NonNull String safetyCenterIssueId) {
+        requireNonNull(safetyCenterIssueId, "safetyCenterIssueId cannot be null");
+
         try {
-            mService.dismissSafetyIssue(safetyCenterIssueId, mContext.getUser().getIdentifier());
+            mService.dismissSafetyCenterIssue(
+                    safetyCenterIssueId, mContext.getUser().getIdentifier());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
     }
 
     /**
-     * Executes the specified action on the specified issue.
+     * Executes the specified Safety Center issue action on the specified Safety Center issue.
      *
-     * @param safetyCenterIssueId the target issue ID returned by {@link SafetyCenterIssue#getId()}
-     * @param safetyCenterActionId the target action ID returned by {@link
-     *                             SafetyCenterIssue.Action#getId()}
+     * @param safetyCenterIssueId       the target issue ID returned by
+     *                                  {@link SafetyCenterIssue#getId()}
+     * @param safetyCenterIssueActionId the target action ID returned by {@link
+     *                                  SafetyCenterIssue.Action#getId()}
      */
     @RequiresPermission(MANAGE_SAFETY_CENTER)
-    public void executeAction(
+    public void executeSafetyCenterIssueAction(
             @NonNull String safetyCenterIssueId,
-            @NonNull String safetyCenterActionId) {
+            @NonNull String safetyCenterIssueActionId) {
+        requireNonNull(safetyCenterIssueId, "safetyCenterIssueId cannot be null");
+        requireNonNull(safetyCenterIssueActionId, "safetyCenterIssueActionId cannot be null");
+
         try {
-            mService.executeAction(
+            mService.executeSafetyCenterIssueAction(
                     safetyCenterIssueId,
-                    safetyCenterActionId,
+                    safetyCenterIssueActionId,
                     mContext.getUser().getIdentifier());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
@@ -458,6 +507,8 @@
      */
     @RequiresPermission(MANAGE_SAFETY_CENTER)
     public void setSafetyCenterConfigOverride(@NonNull SafetyCenterConfig safetyCenterConfig) {
+        requireNonNull(safetyCenterConfig, "safetyCenterConfig cannot be null");
+
         try {
             mService.setSafetyCenterConfigOverride(safetyCenterConfig);
         } catch (RemoteException e) {
@@ -497,14 +548,27 @@
 
         @Override
         public void onSafetyCenterDataChanged(@NonNull SafetyCenterData safetyCenterData) {
-            mExecutor.execute(
-                    () -> mOriginalListener.onSafetyCenterDataChanged(safetyCenterData));
+            requireNonNull(safetyCenterData, "safetyCenterData cannot be null");
+
+            final long identity = Binder.clearCallingIdentity();
+            try {
+                mExecutor.execute(
+                        () -> mOriginalListener.onSafetyCenterDataChanged(safetyCenterData));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
         }
 
         @Override
-        public void onError(@NonNull SafetyCenterError safetyCenterError) {
-            mExecutor.execute(
-                    () -> mOriginalListener.onError(safetyCenterError));
+        public void onError(@NonNull SafetyCenterErrorDetails safetyCenterErrorDetails) {
+            requireNonNull(safetyCenterErrorDetails, "safetyCenterErrorDetails cannot be null");
+
+            final long identity = Binder.clearCallingIdentity();
+            try {
+                mExecutor.execute(() -> mOriginalListener.onError(safetyCenterErrorDetails));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
         }
     }
 }
diff --git a/framework-s/java/android/safetycenter/SafetyEvent.java b/framework-s/java/android/safetycenter/SafetyEvent.java
index 135c002..97bd0fc 100644
--- a/framework-s/java/android/safetycenter/SafetyEvent.java
+++ b/framework-s/java/android/safetycenter/SafetyEvent.java
@@ -53,7 +53,7 @@
             SAFETY_EVENT_TYPE_DEVICE_REBOOTED
     })
     @Retention(RetentionPolicy.SOURCE)
-    public @interface SafetyEventType {
+    public @interface Type {
     }
     /**
      * Indicates that there has been a change of state for safety source, which may be independent
@@ -87,7 +87,7 @@
     /**
      * Indicates that the device was rebooted.
      */
-    public static final int SAFETY_EVENT_TYPE_DEVICE_REBOOTED = 500;
+    public static final int SAFETY_EVENT_TYPE_DEVICE_REBOOTED = 600;
 
     @NonNull
     public static final Creator<SafetyEvent> CREATOR =
@@ -104,8 +104,8 @@
         }
     };
 
-    @SafetyEventType
-    private final int mSafetyEventType;
+    @Type
+    private final int mType;
     @Nullable
     private final String mRefreshBroadcastId;
     @Nullable
@@ -113,30 +113,30 @@
     @Nullable
     private final String mSafetySourceIssueActionId;
 
-    private SafetyEvent(@SafetyEventType int safetyEvent,
+    private SafetyEvent(@Type int type,
             @Nullable String refreshBroadcastId,
             @Nullable String safetySourceIssueId,
             @Nullable String safetySourceIssueActionId) {
-        mSafetyEventType = safetyEvent;
+        mType = type;
         mRefreshBroadcastId = refreshBroadcastId;
         mSafetySourceIssueId = safetySourceIssueId;
         mSafetySourceIssueActionId = safetySourceIssueActionId;
     }
 
     /** Returns the type of the safety event. */
-    @SafetyEventType
-    public int getSafetyEventType() {
-        return mSafetyEventType;
+    @Type
+    public int getType() {
+        return mType;
     }
 
     /**
-     * Returns an optional broadcast id provided by Safety Center when requesting a refresh, through
+     * Returns an optional id provided by Safety Center when requesting a refresh, through
      * {@link SafetyCenterManager#EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID}.
      *
      * <p>This will only be relevant for events of type
      * {@link #SAFETY_EVENT_TYPE_REFRESH_REQUESTED}.
      *
-     * @see #getSafetyEventType()
+     * @see #getType()
      */
     @Nullable
     public String getRefreshBroadcastId() {
@@ -150,7 +150,7 @@
      * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_SUCCEEDED} or
      * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED}.
      *
-     * @see #getSafetyEventType()
+     * @see #getType()
      * @see SafetySourceIssue#getId()
      */
     @Nullable
@@ -166,7 +166,7 @@
      * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_SUCCEEDED} or
      * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED}.
      *
-     * @see #getSafetyEventType()
+     * @see #getType()
      * @see SafetySourceIssue.Action#getId()
      */
     @Nullable
@@ -181,7 +181,7 @@
 
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
-        dest.writeInt(mSafetyEventType);
+        dest.writeInt(mType);
         dest.writeString(mRefreshBroadcastId);
         dest.writeString(mSafetySourceIssueId);
         dest.writeString(mSafetySourceIssueActionId);
@@ -192,7 +192,7 @@
         if (this == o) return true;
         if (!(o instanceof SafetyEvent)) return false;
         SafetyEvent that = (SafetyEvent) o;
-        return mSafetyEventType == that.mSafetyEventType
+        return mType == that.mType
                 && Objects.equals(mRefreshBroadcastId, that.mRefreshBroadcastId)
                 && Objects.equals(mSafetySourceIssueId, that.mSafetySourceIssueId)
                 && Objects.equals(mSafetySourceIssueActionId, that.mSafetySourceIssueActionId);
@@ -200,15 +200,15 @@
 
     @Override
     public int hashCode() {
-        return Objects.hash(mSafetyEventType, mRefreshBroadcastId, mSafetySourceIssueId,
+        return Objects.hash(mType, mRefreshBroadcastId, mSafetySourceIssueId,
                 mSafetySourceIssueActionId);
     }
 
     @Override
     public String toString() {
-        return "SafetySourceDataRewriteReason{"
-                + "mSafetyEventType="
-                + mSafetyEventType
+        return "SafetyEvent{"
+                + "mType="
+                + mType
                 + ", mRefreshBroadcastId='"
                 + mRefreshBroadcastId
                 + '\''
@@ -223,8 +223,8 @@
 
     /** Builder class for {@link SafetyEvent}. */
     public static final class Builder {
-        @SafetyEventType
-        private final int mSafetyEventType;
+        @Type
+        private final int mType;
         @Nullable
         private String mRefreshBroadcastId;
         @Nullable
@@ -233,8 +233,8 @@
         private String mSafetySourceIssueActionId;
 
         /** Creates a {@link Builder} for {@link SafetyEvent}. */
-        public Builder(@SafetyEventType int safetyEventType) {
-            mSafetyEventType = safetyEventType;
+        public Builder(@Type int type) {
+            mType = type;
         }
 
         /**
@@ -244,7 +244,7 @@
          * <p>This will only be relevant for events of type
          * {@link #SAFETY_EVENT_TYPE_REFRESH_REQUESTED}.
          *
-         * @see #getSafetyEventType()
+         * @see #getType()
          */
         @NonNull
         public Builder setRefreshBroadcastId(@Nullable String refreshBroadcastId) {
@@ -259,7 +259,7 @@
          * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_SUCCEEDED} or
          * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED}.
          *
-         * @see #getSafetyEventType()
+         * @see #getType()
          * @see SafetySourceIssue#getId()
          */
         @NonNull
@@ -276,7 +276,7 @@
          * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_SUCCEEDED} or
          * {@link #SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED}.
          *
-         * @see #getSafetyEventType()
+         * @see #getType()
          * @see SafetySourceIssue.Action#getId()
          */
         @NonNull
@@ -290,7 +290,7 @@
          */
         @NonNull
         public SafetyEvent build() {
-            return new SafetyEvent(mSafetyEventType, mRefreshBroadcastId, mSafetySourceIssueId,
+            return new SafetyEvent(mType, mRefreshBroadcastId, mSafetySourceIssueId,
                     mSafetySourceIssueActionId);
         }
     }
diff --git a/framework-s/java/android/safetycenter/SafetySourceData.java b/framework-s/java/android/safetycenter/SafetySourceData.java
index 6e4fe11..954b279 100644
--- a/framework-s/java/android/safetycenter/SafetySourceData.java
+++ b/framework-s/java/android/safetycenter/SafetySourceData.java
@@ -39,7 +39,6 @@
  * @hide
  */
 @SystemApi
-// TODO(b/207399899): Add timestamp field(s) to data model classes.
 @RequiresApi(TIRAMISU)
 public final class SafetySourceData implements Parcelable {
 
@@ -48,11 +47,9 @@
             new Parcelable.Creator<SafetySourceData>() {
                 @Override
                 public SafetySourceData createFromParcel(Parcel in) {
-                    SafetySourceStatus status =
-                            in.readParcelable(SafetySourceStatus.class.getClassLoader(),
-                                    SafetySourceStatus.class);
-                    List<SafetySourceIssue> issues = new ArrayList<>();
-                    in.readParcelableList(issues, SafetySourceIssue.class.getClassLoader());
+                    SafetySourceStatus status = in.readTypedObject(SafetySourceStatus.CREATOR);
+                    List<SafetySourceIssue> issues =
+                            in.createTypedArrayList(SafetySourceIssue.CREATOR);
                     return new SafetySourceData(status, issues);
                 }
 
@@ -92,8 +89,8 @@
 
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
-        dest.writeParcelable(mStatus, flags);
-        dest.writeParcelableList(mIssues, flags);
+        dest.writeTypedObject(mStatus, flags);
+        dest.writeTypedList(mIssues);
     }
 
     @Override
diff --git a/framework-s/java/android/safetycenter/SafetySourceError.aidl b/framework-s/java/android/safetycenter/SafetySourceError.aidl
deleted file mode 100644
index 24c5f80..0000000
--- a/framework-s/java/android/safetycenter/SafetySourceError.aidl
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright (C) 2021 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 android.safetycenter;
-
-/**
- * Parcelable AIDL SafetySourceError.
- *
- * @hide
- */
-parcelable SafetySourceError;
\ No newline at end of file
diff --git a/framework-s/java/android/safetycenter/SafetyCenterError.aidl b/framework-s/java/android/safetycenter/SafetySourceErrorDetails.aidl
similarity index 88%
rename from framework-s/java/android/safetycenter/SafetyCenterError.aidl
rename to framework-s/java/android/safetycenter/SafetySourceErrorDetails.aidl
index 032dd8a..96296d2 100644
--- a/framework-s/java/android/safetycenter/SafetyCenterError.aidl
+++ b/framework-s/java/android/safetycenter/SafetySourceErrorDetails.aidl
@@ -17,8 +17,8 @@
 package android.safetycenter;
 
 /**
- * Parcelable AIDL SafetyCenterError.
+ * Parcelable AIDL SafetySourceErrorDetails.
  *
  * @hide
  */
-parcelable SafetyCenterError;
\ No newline at end of file
+parcelable SafetySourceErrorDetails;
\ No newline at end of file
diff --git a/framework-s/java/android/safetycenter/SafetySourceError.java b/framework-s/java/android/safetycenter/SafetySourceErrorDetails.java
similarity index 71%
rename from framework-s/java/android/safetycenter/SafetySourceError.java
rename to framework-s/java/android/safetycenter/SafetySourceErrorDetails.java
index afc33ea..1b42877 100644
--- a/framework-s/java/android/safetycenter/SafetySourceError.java
+++ b/framework-s/java/android/safetycenter/SafetySourceErrorDetails.java
@@ -28,17 +28,17 @@
 import java.util.Objects;
 
 /**
- * An error that a Safety Source may report to the Safety Center.
+ * Details of an error that a Safety Source may report to the Safety Center.
  *
  * @hide
  */
 @SystemApi
 @RequiresApi(TIRAMISU)
-public final class SafetySourceError implements Parcelable {
+public final class SafetySourceErrorDetails implements Parcelable {
     @NonNull
     private final SafetyEvent mSafetyEvent;
 
-    public SafetySourceError(@NonNull SafetyEvent safetyEvent) {
+    public SafetySourceErrorDetails(@NonNull SafetyEvent safetyEvent) {
         mSafetyEvent = safetyEvent;
     }
 
@@ -53,7 +53,7 @@
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
-        SafetySourceError that = (SafetySourceError) o;
+        SafetySourceErrorDetails that = (SafetySourceErrorDetails) o;
         return mSafetyEvent.equals(that.mSafetyEvent);
     }
 
@@ -64,7 +64,7 @@
 
     @Override
     public String toString() {
-        return "SafetySourceError{"
+        return "SafetySourceErrorDetails{"
                 + "mSafetyEvent="
                 + mSafetyEvent
                 + '}';
@@ -81,16 +81,17 @@
     }
 
     @NonNull
-    public static final Creator<SafetySourceError> CREATOR = new Creator<SafetySourceError>() {
+    public static final Creator<SafetySourceErrorDetails> CREATOR =
+            new Creator<SafetySourceErrorDetails>() {
         @Override
-        public SafetySourceError createFromParcel(Parcel in) {
-            return new SafetySourceError(in.readParcelable(SafetyEvent.class.getClassLoader(),
-                    SafetyEvent.class));
+        public SafetySourceErrorDetails createFromParcel(Parcel in) {
+            return new SafetySourceErrorDetails(
+                    in.readParcelable(SafetyEvent.class.getClassLoader(), SafetyEvent.class));
         }
 
         @Override
-        public SafetySourceError[] newArray(int size) {
-            return new SafetySourceError[0];
+        public SafetySourceErrorDetails[] newArray(int size) {
+            return new SafetySourceErrorDetails[0];
         }
     };
 }
diff --git a/framework-s/java/android/safetycenter/SafetySourceIssue.java b/framework-s/java/android/safetycenter/SafetySourceIssue.java
index 1692e97..950fc35 100644
--- a/framework-s/java/android/safetycenter/SafetySourceIssue.java
+++ b/framework-s/java/android/safetycenter/SafetySourceIssue.java
@@ -25,7 +25,7 @@
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.StringDef;
+import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.app.PendingIntent;
 import android.os.Parcel;
@@ -56,8 +56,9 @@
 public final class SafetySourceIssue implements Parcelable {
 
     /**
-     * Indicates an informational message. This severity will be reflected in the UI through a
-     * green icon.
+     * Indicates an informational message.
+     *
+     * <p>This severity will be reflected in the UI through a green icon.
      *
      * <p>Issues with this severity will be dismissible by the user from the UI, and will not
      * trigger a confirmation dialog upon a user attempting to dismiss the warning.
@@ -65,8 +66,9 @@
     public static final int SEVERITY_LEVEL_INFORMATION = 200;
 
     /**
-     * Indicates a medium-severity issue which the user is encouraged to act on. This severity will
-     * be reflected in the UI through a yellow icon.
+     * Indicates a medium-severity issue which the user is encouraged to act on.
+     *
+     * <p>This severity will be reflected in the UI through a yellow icon.
      *
      * <p>Issues with this severity will be dismissible by the user from the UI, and will trigger a
      * confirmation dialog upon a user attempting to dismiss the warning.
@@ -74,8 +76,9 @@
     public static final int SEVERITY_LEVEL_RECOMMENDATION = 300;
 
     /**
-     * Indicates a critical or urgent safety issue that should be addressed by the user. This
-     * severity will be reflected in the UI through a red icon.
+     * Indicates a critical or urgent safety issue that should be addressed by the user.
+     *
+     * <p>This severity will be reflected in the UI through a red icon.
      *
      * <p>Issues with this severity will be dismissible by the user from the UI, and will trigger a
      * confirmation dialog upon a user attempting to dismiss the warning.
@@ -83,13 +86,13 @@
     public static final int SEVERITY_LEVEL_CRITICAL_WARNING = 400;
 
     /** Indicates that the risk associated with the issue is related to a user's device safety. */
-    public static final String ISSUE_CATEGORY_DEVICE = "issue_category_device";
+    public static final int ISSUE_CATEGORY_DEVICE = 100;
 
     /** Indicates that the risk associated with the issue is related to a user's account safety. */
-    public static final String ISSUE_CATEGORY_ACCOUNT = "issue_category_account";
+    public static final int ISSUE_CATEGORY_ACCOUNT = 200;
 
     /** Indicates that the risk associated with the issue is related to a user's general safety. */
-    public static final String ISSUE_CATEGORY_GENERAL = "issue_category_general";
+    public static final int ISSUE_CATEGORY_GENERAL = 300;
 
     @NonNull
     public static final Parcelable.Creator<SafetySourceIssue> CREATOR =
@@ -104,9 +107,8 @@
                     CharSequence summary =
                             requireNonNull(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in));
                     int severityLevel = in.readInt();
-                    String issueCategory = requireNonNull(in.readString());
-                    List<Action> actions = new ArrayList<>();
-                    in.readParcelableList(actions, Action.class.getClassLoader());
+                    int issueCategory = in.readInt();
+                    List<Action> actions = in.createTypedArrayList(Action.CREATOR);
                     PendingIntent >
                             PendingIntent.readPendingIntentOrNullFromParcel(in);
                     String issueTypeId = requireNonNull(in.readString());
@@ -133,9 +135,8 @@
     private final List<Action> mActions;
     @Nullable
     private final PendingIntent mOnDismissPendingIntent;
-    @NonNull
     @IssueCategory
-    private final String mIssueCategory;
+    private final int mIssueCategory;
     @NonNull
     private final String mIssueTypeId;
 
@@ -144,7 +145,7 @@
             @Nullable CharSequence subtitle,
             @NonNull CharSequence summary,
             @SeverityLevel int severityLevel,
-            @NonNull @IssueCategory String issueCategory,
+            @IssueCategory int issueCategory,
             @NonNull List<Action> actions,
             @Nullable PendingIntent onDismissPendingIntent,
             @NonNull String issueTypeId) {
@@ -202,9 +203,8 @@
      *
      * <p>The default category will be {@link #ISSUE_CATEGORY_GENERAL}.
      */
-    @NonNull
     @IssueCategory
-    public String getIssueCategory() {
+    public int getIssueCategory() {
         return mIssueCategory;
     }
 
@@ -264,8 +264,8 @@
         TextUtils.writeToParcel(mSubtitle, dest, flags);
         TextUtils.writeToParcel(mSummary, dest, flags);
         dest.writeInt(mSeverityLevel);
-        dest.writeString(mIssueCategory);
-        dest.writeParcelableList(mActions, flags);
+        dest.writeInt(mIssueCategory);
+        dest.writeTypedList(mActions);
         PendingIntent.writePendingIntentOrNullToParcel(mOnDismissPendingIntent, dest);
         dest.writeString(mIssueTypeId);
     }
@@ -278,9 +278,9 @@
         return mSeverityLevel == that.mSeverityLevel
                 && TextUtils.equals(mId, that.mId)
                 && TextUtils.equals(mTitle, that.mTitle)
-                && Objects.equals(mSubtitle, that.mSubtitle)
+                && TextUtils.equals(mSubtitle, that.mSubtitle)
                 && TextUtils.equals(mSummary, that.mSummary)
-                && TextUtils.equals(mIssueCategory, that.mIssueCategory)
+                && mIssueCategory == that.mIssueCategory
                 && mActions.equals(that.mActions)
                 && Objects.equals(mOnDismissPendingIntent, that.mOnDismissPendingIntent)
                 && TextUtils.equals(mIssueTypeId, that.mIssueTypeId);
@@ -350,9 +350,9 @@
      * safety, then choose the closest area or default to {@link #ISSUE_CATEGORY_GENERAL}.
      *
      * @hide
-     * @see Builder#setIssueCategory(String)
+     * @see Builder#setIssueCategory(int)
      */
-    @StringDef(prefix = {"ISSUE_CATEGORY_"}, value = {
+    @IntDef(prefix = {"ISSUE_CATEGORY_"}, value = {
             ISSUE_CATEGORY_DEVICE,
             ISSUE_CATEGORY_ACCOUNT,
             ISSUE_CATEGORY_GENERAL,
@@ -389,10 +389,10 @@
                                         TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in));
                         PendingIntent pendingIntent =
                                 requireNonNull(PendingIntent.readPendingIntentOrNullFromParcel(in));
-                        boolean resolving = in.readBoolean();
+                        boolean willResolve = in.readBoolean();
                         CharSequence successMessage =
                                 TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
-                        return new Action(id, label, pendingIntent, resolving, successMessage);
+                        return new Action(id, label, pendingIntent, willResolve, successMessage);
                     }
 
                     @Override
@@ -407,7 +407,7 @@
         private final CharSequence mLabel;
         @NonNull
         private final PendingIntent mPendingIntent;
-        private final boolean mResolving;
+        private final boolean mWillResolve;
         @Nullable
         private final CharSequence mSuccessMessage;
 
@@ -415,12 +415,12 @@
                 String id,
                 @NonNull CharSequence label,
                 @NonNull PendingIntent pendingIntent,
-                boolean resolving,
+                boolean willResolve,
                 @Nullable CharSequence successMessage) {
             mId = id;
             mLabel = label;
             mPendingIntent = pendingIntent;
-            mResolving = resolving;
+            mWillResolve = willResolve;
             mSuccessMessage = successMessage;
         }
 
@@ -458,8 +458,8 @@
          * to be considered resolved i.e. the issue will no longer need to be conveyed to the user
          * in the UI.
          */
-        public boolean isResolving() {
-            return mResolving;
+        public boolean willResolve() {
+            return mWillResolve;
         }
 
         /**
@@ -481,7 +481,7 @@
             dest.writeString(mId);
             TextUtils.writeToParcel(mLabel, dest, flags);
             mPendingIntent.writeToParcel(dest, flags);
-            dest.writeBoolean(mResolving);
+            dest.writeBoolean(mWillResolve);
             TextUtils.writeToParcel(mSuccessMessage, dest, flags);
         }
 
@@ -493,13 +493,13 @@
             return mId.equals(that.mId)
                     && TextUtils.equals(mLabel, that.mLabel)
                     && mPendingIntent.equals(that.mPendingIntent)
-                    && mResolving == that.mResolving
-                    && Objects.equals(mSuccessMessage, that.mSuccessMessage);
+                    && mWillResolve == that.mWillResolve
+                    && TextUtils.equals(mSuccessMessage, that.mSuccessMessage);
         }
 
         @Override
         public int hashCode() {
-            return Objects.hash(mId, mLabel, mPendingIntent, mResolving, mSuccessMessage);
+            return Objects.hash(mId, mLabel, mPendingIntent, mWillResolve, mSuccessMessage);
         }
 
         @Override
@@ -508,7 +508,7 @@
                     + "mId=" + mId
                     + ", mLabel=" + mLabel
                     + ", mPendingIntent=" + mPendingIntent
-                    + ", mResolving=" + mResolving
+                    + ", mWillResolve=" + mWillResolve
                     + ", mSuccessMessage=" + mSuccessMessage
                     + '}';
         }
@@ -521,7 +521,7 @@
             private final CharSequence mLabel;
             @NonNull
             private final PendingIntent mPendingIntent;
-            private boolean mResolving = false;
+            private boolean mWillResolve = false;
             @Nullable
             private CharSequence mSuccessMessage;
 
@@ -538,11 +538,12 @@
             /**
              * Sets whether the action will resolve the safety issue. Defaults to false.
              *
-             * @see #isResolving()
+             * @see #willResolve()
              */
+            @SuppressLint("MissingGetterMatchingBuilder")
             @NonNull
-            public Builder setResolving(boolean resolving) {
-                mResolving = resolving;
+            public Builder setWillResolve(boolean willResolve) {
+                mWillResolve = willResolve;
                 return this;
             }
 
@@ -559,7 +560,7 @@
             /** Creates the {@link Action} defined by this {@link Builder}. */
             @NonNull
             public Action build() {
-                return new Action(mId, mLabel, mPendingIntent, mResolving, mSuccessMessage);
+                return new Action(mId, mLabel, mPendingIntent, mWillResolve, mSuccessMessage);
             }
         }
     }
@@ -576,9 +577,8 @@
         private final CharSequence mSummary;
         @SeverityLevel
         private final int mSeverityLevel;
-        @NonNull
         @IssueCategory
-        private String mIssueCategory = ISSUE_CATEGORY_GENERAL;
+        private int mIssueCategory = ISSUE_CATEGORY_GENERAL;
         @Nullable
         private PendingIntent mOnDismissPendingIntent;
         @NonNull
@@ -613,7 +613,7 @@
          * <p>The default category will be {@link #ISSUE_CATEGORY_GENERAL}.
          */
         @NonNull
-        public Builder setIssueCategory(@NonNull @IssueCategory String issueCategory) {
+        public Builder setIssueCategory(@IssueCategory int issueCategory) {
             mIssueCategory = issueCategory;
             return this;
         }
diff --git a/framework-s/java/android/safetycenter/SafetySourceStatus.java b/framework-s/java/android/safetycenter/SafetySourceStatus.java
index 7579f43..83a5410 100644
--- a/framework-s/java/android/safetycenter/SafetySourceStatus.java
+++ b/framework-s/java/android/safetycenter/SafetySourceStatus.java
@@ -23,7 +23,6 @@
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.StringDef;
 import android.annotation.SystemApi;
 import android.app.PendingIntent;
 import android.os.Parcel;
@@ -49,25 +48,30 @@
     /**
      * Indicates that no status is currently associated with the information. This may be due to the
      * source not having sufficient information or opinion on the status level.
-     * This status will be reflected in the UI through a grey icon.
+     *
+     * <p>This status will be reflected in the UI through a grey icon.
      */
     public static final int STATUS_LEVEL_NONE = 100;
 
     /**
-     * Indicates that no issues were detected. This status will be reflected in the UI through a
-     * green icon.
+     * Indicates that no issues were detected.
+     *
+     * <p>This status will be reflected in the UI through a green icon.
      */
     public static final int STATUS_LEVEL_OK = 200;
 
     /**
-     * Indicates the presence of a medium-status issue which the user is encouraged to act on.
-     * This status will be reflected in the UI through a yellow icon.
+     * Indicates the presence of a medium-level issue which the user is encouraged to act on.
+     *
+     * <p>This status will be reflected in the UI through a yellow icon.
      */
     public static final int STATUS_LEVEL_RECOMMENDATION = 300;
 
     /**
      * Indicates the presence of a critical or urgent safety issue that should be addressed by the
-     * user. This status will be reflected in the UI through a red icon.
+     * user.
+     *
+     * <p>This status will be reflected in the UI through a red icon.
      */
     public static final int STATUS_LEVEL_CRITICAL_WARNING = 400;
 
@@ -83,8 +87,7 @@
                     int statusLevel = in.readInt();
                     PendingIntent pendingIntent =
                             requireNonNull(PendingIntent.readPendingIntentOrNullFromParcel(in));
-                    IconAction iconAction = in.readParcelable(IconAction.class.getClassLoader(),
-                            IconAction.class);
+                    IconAction iconAction = in.readTypedObject(IconAction.CREATOR);
                     boolean enabled = in.readBoolean();
                     return new SafetySourceStatus(title, summary, statusLevel, pendingIntent,
                             iconAction, enabled);
@@ -180,7 +183,7 @@
         TextUtils.writeToParcel(mSummary, dest, flags);
         dest.writeInt(mStatusLevel);
         mPendingIntent.writeToParcel(dest, flags);
-        dest.writeParcelable(mIconAction, flags);
+        dest.writeTypedObject(mIconAction, flags);
         dest.writeBoolean(mEnabled);
     }
 
@@ -242,7 +245,7 @@
                 new Parcelable.Creator<IconAction>() {
                     @Override
                     public IconAction createFromParcel(Parcel in) {
-                        String iconType = requireNonNull(in.readString());
+                        int iconType = in.readInt();
                         PendingIntent pendingIntent =
                                 requireNonNull(PendingIntent.readPendingIntentOrNullFromParcel(in));
                         return new IconAction(iconType, pendingIntent);
@@ -254,14 +257,12 @@
                     }
                 };
 
-        @NonNull
         @IconType
-        private final String mIconType;
+        private final int mIconType;
         @NonNull
         private final PendingIntent mPendingIntent;
 
-        public IconAction(@NonNull @IconType String iconType,
-                @NonNull PendingIntent pendingIntent) {
+        public IconAction(@IconType int iconType, @NonNull PendingIntent pendingIntent) {
             this.mIconType = iconType;
             this.mPendingIntent = pendingIntent;
         }
@@ -271,9 +272,8 @@
          *
          * <p>The icon type should indicate what action will be performed if when invoked.
          */
-        @NonNull
         @IconType
-        public String getIconType() {
+        public int getIconType() {
             return mIconType;
         }
 
@@ -290,7 +290,7 @@
 
         @Override
         public void writeToParcel(@NonNull Parcel dest, int flags) {
-            dest.writeString(mIconType);
+            dest.writeInt(mIconType);
             mPendingIntent.writeToParcel(dest, flags);
         }
 
@@ -299,8 +299,7 @@
             if (this == o) return true;
             if (!(o instanceof IconAction)) return false;
             IconAction that = (IconAction) o;
-            return TextUtils.equals(mIconType, that.mIconType)
-                    && mPendingIntent.equals(that.mPendingIntent);
+            return mIconType == that.mIconType && mPendingIntent.equals(that.mPendingIntent);
         }
 
         @Override
@@ -319,17 +318,17 @@
         }
 
         /** Indicates a gear (cog) icon. */
-        public static final String ICON_TYPE_GEAR = "icon_type_gear";
+        public static final int ICON_TYPE_GEAR = 100;
 
         /** Indicates an information icon. */
-        public static final String ICON_TYPE_INFO = "icon_type_info";
+        public static final int ICON_TYPE_INFO = 200;
 
         /**
          * All possible icons which can be displayed in an {@link IconAction}.
          *
          * @hide
          */
-        @StringDef(prefix = {"ICON_TYPE_"}, value = {
+        @IntDef(prefix = {"ICON_TYPE_"}, value = {
                 ICON_TYPE_GEAR,
                 ICON_TYPE_INFO,
 
@@ -354,8 +353,6 @@
      *
      * @hide
      */
-    // TODO(b/205806500): Determine full list of status levels. We may add a new one to signify
-    //  that there was an error retrieving data.
     @IntDef(prefix = {"STATUS_LEVEL_"}, value = {
             STATUS_LEVEL_NONE,
             STATUS_LEVEL_OK,
diff --git a/service/Android.bp b/service/Android.bp
index 3cdcf05..a837fff 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -17,7 +17,7 @@
 }
 
 filegroup {
-    name: "service-permission-sources",
+    name: "service-permission-java-sources",
     srcs: [
         "java/**/*.java",
         // Exclude Kotlin sources for T.
@@ -28,15 +28,15 @@
 }
 
 filegroup {
-    name: "service-permission-protos",
+    name: "service-permission-streaming-proto-sources",
     srcs: [
-        "proto/**/*.proto",
+        "proto/role_service.proto",
     ],
     visibility: ["//frameworks/base"],
 }
 
 gensrcs {
-    name: "service-permission-javastream-protos",
+    name: "service-permission-streaming-proto-java-gen",
     depfile: true,
 
     tools: [
@@ -56,7 +56,7 @@
         "&& $(location soong_zip) -jar -o $(out) -C $(genDir)/$(in) -D $(genDir)/$(in)",
 
     srcs: [
-        ":service-permission-protos",
+        ":service-permission-streaming-proto-sources",
     ],
     output_extension: "srcjar",
 }
@@ -87,11 +87,11 @@
         "//frameworks/base/apex/permission/tests",
         "//frameworks/base/services/tests/mockingservicestests",
         "//frameworks/base/services/tests/servicestests",
-        "//packages/modules/Permission/tests",
+        "//packages/modules/Permission/tests/apex",
     ],
     srcs: [
-        ":service-permission-sources",
-        ":service-permission-javastream-protos",
+        ":service-permission-java-sources",
+        ":service-permission-streaming-proto-java-gen",
     ],
     libs: [
         "androidx.annotation_annotation",
diff --git a/service/java/com/android/safetycenter/SafetyCenterDataTracker.java b/service/java/com/android/safetycenter/SafetyCenterDataTracker.java
index e119ce6..b052463 100644
--- a/service/java/com/android/safetycenter/SafetyCenterDataTracker.java
+++ b/service/java/com/android/safetycenter/SafetyCenterDataTracker.java
@@ -91,7 +91,7 @@
     @Nullable
     SafetyCenterData setSafetySourceData(
             @NonNull String safetySourceId,
-            @NonNull SafetySourceData safetySourceData,
+            @Nullable SafetySourceData safetySourceData,
             @NonNull String packageName,
             @UserIdInt int userId) {
         if (!configContains(safetySourceId, packageName)) {
@@ -101,7 +101,7 @@
 
         Key key = Key.of(safetySourceId, packageName, userId);
         SafetySourceData existingSafetySourceData = mSafetySourceDataForKey.get(key);
-        if (safetySourceData.equals(existingSafetySourceData)) {
+        if (Objects.equals(safetySourceData, existingSafetySourceData)) {
             return null;
         }
 
@@ -152,6 +152,25 @@
         return getSafetyCenterData(safetyCenterConfig, userId);
     }
 
+    /**
+     * Returns a default {@link SafetyCenterData} object to be returned when the API is disabled.
+     */
+    @NonNull
+    static SafetyCenterData getDefaultSafetyCenterData() {
+        return new SafetyCenterData(
+                new SafetyCenterStatus.Builder()
+                        .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN)
+                        .setTitle(getSafetyCenterStatusTitle(
+                                SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN))
+                        .setSummary(getSafetyCenterStatusSummary(
+                                SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN))
+                        .build(),
+                emptyList(),
+                emptyList(),
+                emptyList()
+        );
+    }
+
     // TODO(b/219702252): Create a more efficient data structure for this, and update it when the
     //  config changes.
     private boolean configContains(
@@ -479,22 +498,6 @@
         }
     }
 
-    @NonNull
-    private static SafetyCenterData getDefaultSafetyCenterData() {
-        return new SafetyCenterData(
-                new SafetyCenterStatus.Builder()
-                        .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN)
-                        .setTitle(getSafetyCenterStatusTitle(
-                                SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN))
-                        .setSummary(getSafetyCenterStatusSummary(
-                                SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN))
-                        .build(),
-                emptyList(),
-                emptyList(),
-                emptyList()
-        );
-    }
-
     @Nullable
     private static SafetySourceStatus getSafetySourceStatus(
             @Nullable SafetySourceData safetySourceData) {
diff --git a/service/java/com/android/safetycenter/SafetyCenterService.java b/service/java/com/android/safetycenter/SafetyCenterService.java
index 98edc8a..cea0ae4 100644
--- a/service/java/com/android/safetycenter/SafetyCenterService.java
+++ b/service/java/com/android/safetycenter/SafetyCenterService.java
@@ -41,10 +41,11 @@
 import android.safetycenter.SafetyCenterData;
 import android.safetycenter.SafetyEvent;
 import android.safetycenter.SafetySourceData;
-import android.safetycenter.SafetySourceError;
+import android.safetycenter.SafetySourceErrorDetails;
 import android.safetycenter.config.SafetyCenterConfig;
 import android.safetycenter.config.SafetySource;
 import android.safetycenter.config.SafetySourcesGroup;
+import android.util.Log;
 
 import androidx.annotation.Keep;
 import androidx.annotation.RequiresApi;
@@ -111,17 +112,7 @@
             // TODO(b/214568975): Decide if we should disable safety center if there is a problem
             //  reading the config.
 
-            // We don't require the caller to have READ_DEVICE_CONFIG permission.
-            final long callingId = Binder.clearCallingIdentity();
-            try {
-                return DeviceConfig.getBoolean(
-                        DeviceConfig.NAMESPACE_PRIVACY,
-                        PROPERTY_SAFETY_CENTER_ENABLED,
-                        /* defaultValue = */ false)
-                        && getSafetyCenterConfigValue();
-            } finally {
-                Binder.restoreCallingIdentity(callingId);
-            }
+            return isApiEnabled();
         }
 
         @Override
@@ -138,6 +129,9 @@
             // TODO(b/205706756): Security: check certs?
             getContext().enforceCallingOrSelfPermission(SEND_SAFETY_CENTER_UPDATE,
                     "setSafetySourceData");
+            if (!checkApiEnabled("setSafetySourceData")) {
+                return;
+            }
             // TODO(b/218812582): Validate the SafetySourceData.
 
             SafetyCenterData safetyCenterData;
@@ -175,6 +169,9 @@
             // TODO(b/205706756): Security: check certs?
             getContext().enforceCallingOrSelfPermission(
                     SEND_SAFETY_CENTER_UPDATE, "getSafetySourceData");
+            if (!checkApiEnabled("getSafetySourceData")) {
+                return null;
+            }
 
             synchronized (mApiLock) {
                 return mSafetyCenterDataTracker.getSafetySourceData(safetySourceId, packageName,
@@ -185,7 +182,7 @@
         @Override
         public void reportSafetySourceError(
                 @NonNull String safetySourceId,
-                @NonNull SafetySourceError error,
+                @NonNull SafetySourceErrorDetails errorDetails,
                 @NonNull String packageName,
                 @UserIdInt int userId) {
             mAppOpsManager.checkPackage(Binder.getCallingUid(), packageName);
@@ -194,6 +191,9 @@
                     userId, false, "reportSafetySourceError", getContext());
             getContext().enforceCallingOrSelfPermission(
                     SEND_SAFETY_CENTER_UPDATE, "reportSafetySourceError");
+            if (!checkApiEnabled("reportSafetySourceError")) {
+                return;
+            }
             // TODO(b/218379298): Add implementation
         }
 
@@ -206,6 +206,9 @@
                     userId, false, "refreshSafetySources", getContext());
             getContext().enforceCallingPermission(
                     MANAGE_SAFETY_CENTER, "refreshSafetySources");
+            if (!checkApiEnabled("refreshSafetySources")) {
+                return;
+            }
 
             // We don't require the caller to have INTERACT_ACROSS_USERS and
             // START_FOREGROUND_SERVICES_FROM_BACKGROUND permissions.
@@ -227,6 +230,9 @@
                     userId, false, "getSafetyCenterData", getContext());
             getContext().enforceCallingOrSelfPermission(
                     MANAGE_SAFETY_CENTER, "getSafetyCenterData");
+            if (!checkApiEnabled("getSafetyCenterData")) {
+                return SafetyCenterDataTracker.getDefaultSafetyCenterData();
+            }
 
             synchronized (mApiLock) {
                 return mSafetyCenterDataTracker.getSafetyCenterData(userId);
@@ -242,6 +248,9 @@
                     userId, false, "addOnSafetyCenterDataChangedListener", getContext());
             getContext().enforceCallingOrSelfPermission(
                     MANAGE_SAFETY_CENTER, "addOnSafetyCenterDataChangedListener");
+            if (!checkApiEnabled("addOnSafetyCenterDataChangedListener")) {
+                return;
+            }
 
             SafetyCenterData safetyCenterData;
             synchronized (mApiLock) {
@@ -263,6 +272,9 @@
                     userId, false, "removeOnSafetyCenterDataChangedListener", getContext());
             getContext().enforceCallingOrSelfPermission(
                     MANAGE_SAFETY_CENTER, "removeOnSafetyCenterDataChangedListener");
+            if (!checkApiEnabled("removeOnSafetyCenterDataChangedListener")) {
+                return;
+            }
 
             synchronized (mApiLock) {
                 mSafetyCenterListeners.removeListener(listener, userId);
@@ -270,26 +282,32 @@
         }
 
         @Override
-        public void dismissSafetyIssue(String issueId, @UserIdInt int userId) {
+        public void dismissSafetyCenterIssue(String issueId, @UserIdInt int userId) {
             // TODO(b/217235899): Finalize cross-user behavior.
             PermissionUtils.enforceCrossUserPermission(
-                    userId, false, "dismissSafetyIssue", getContext());
+                    userId, false, "dismissSafetyCenterIssue", getContext());
             getContext().enforceCallingOrSelfPermission(
-                    MANAGE_SAFETY_CENTER, "dismissSafetyIssue");
+                    MANAGE_SAFETY_CENTER, "dismissSafetyCenterIssue");
+            if (!checkApiEnabled("dismissSafetyCenterIssue")) {
+                return;
+            }
             // TODO(b/202387059): Implement issue dismissal.
 
         }
 
         @Override
-        public void executeAction(
+        public void executeSafetyCenterIssueAction(
                 @NonNull String safetyCenterIssueId,
                 @NonNull String safetyCenterActionId,
                 @UserIdInt int userId) {
             // TODO(b/217235899): Finalize cross-user behavior.
             PermissionUtils.enforceCrossUserPermission(
-                    userId, false, "executeAction", getContext());
+                    userId, false, "executeSafetyCenterIssueAction", getContext());
             getContext().enforceCallingOrSelfPermission(MANAGE_SAFETY_CENTER,
-                    "executeAction");
+                    "executeSafetyCenterIssueAction");
+            if (!checkApiEnabled("executeSafetyCenterIssueAction")) {
+                return;
+            }
             // TODO(b/218379298): Add implementation
         }
 
@@ -297,6 +315,9 @@
         public void clearAllSafetySourceData() {
             getContext().enforceCallingOrSelfPermission(
                     MANAGE_SAFETY_CENTER, "clearAllSafetySourceData");
+            if (!checkApiEnabled("clearAllSafetySourceData")) {
+                return;
+            }
 
             synchronized (mApiLock) {
                 mSafetyCenterDataTracker.clear();
@@ -308,6 +329,9 @@
                 @NonNull SafetyCenterConfig safetyCenterConfig) {
             getContext().enforceCallingOrSelfPermission(MANAGE_SAFETY_CENTER,
                     "setSafetyCenterConfigOverride");
+            if (!checkApiEnabled("setSafetyCenterConfigOverride")) {
+                return;
+            }
 
             synchronized (mRefreshLock) {
                 // TODO(b/217944317): Implement properly by overriding config in
@@ -335,6 +359,9 @@
         public void clearSafetyCenterConfigOverride() {
             getContext().enforceCallingOrSelfPermission(
                     MANAGE_SAFETY_CENTER, "clearSafetyCenterConfigOverride");
+            if (!checkApiEnabled("clearSafetyCenterConfigOverride")) {
+                return;
+            }
 
             synchronized (mRefreshLock) {
                 mSafetyCenterRefreshManager
@@ -342,6 +369,23 @@
             }
         }
 
+        private boolean isApiEnabled() {
+            return getSafetyCenterConfigValue() && getDeviceConfigSafetyCenterEnabledProperty();
+        }
+
+        private boolean getDeviceConfigSafetyCenterEnabledProperty() {
+            // This call requires the READ_DEVICE_CONFIG permission.
+            final long callingId = Binder.clearCallingIdentity();
+            try {
+                return DeviceConfig.getBoolean(
+                        DeviceConfig.NAMESPACE_PRIVACY,
+                        PROPERTY_SAFETY_CENTER_ENABLED,
+                        /* defaultValue = */ false);
+            } finally {
+                Binder.restoreCallingIdentity(callingId);
+            }
+        }
+
         private boolean getSafetyCenterConfigValue() {
             return getContext().getResources().getBoolean(Resources.getSystem().getIdentifier(
                     "config_enableSafetyCenter",
@@ -363,5 +407,13 @@
             throw new SecurityException(message + " requires any of: "
                     + Arrays.toString(permissions) + ", but none were granted");
         }
+
+        private boolean checkApiEnabled(@NonNull String message) {
+            if (!isApiEnabled()) {
+                Log.w(TAG, String.format("Called %s, but Safety Center is disabled", message));
+                return false;
+            }
+            return true;
+        }
     }
 }
diff --git a/service/proto/com/android/role/roleservice.proto b/service/proto/role_service.proto
similarity index 100%
rename from service/proto/com/android/role/roleservice.proto
rename to service/proto/role_service.proto
diff --git a/tests/Android.bp b/tests/apex/Android.bp
similarity index 100%
rename from tests/Android.bp
rename to tests/apex/Android.bp
diff --git a/tests/AndroidManifest.xml b/tests/apex/AndroidManifest.xml
similarity index 100%
rename from tests/AndroidManifest.xml
rename to tests/apex/AndroidManifest.xml
diff --git a/tests/AndroidTest.xml b/tests/apex/AndroidTest.xml
similarity index 100%
rename from tests/AndroidTest.xml
rename to tests/apex/AndroidTest.xml
diff --git a/tests/java/com/android/permission/persistence/RuntimePermissionsPersistenceTest.kt b/tests/apex/java/com/android/permission/persistence/RuntimePermissionsPersistenceTest.kt
similarity index 100%
rename from tests/java/com/android/permission/persistence/RuntimePermissionsPersistenceTest.kt
rename to tests/apex/java/com/android/permission/persistence/RuntimePermissionsPersistenceTest.kt
diff --git a/tests/java/com/android/role/persistence/RolesPersistenceTest.kt b/tests/apex/java/com/android/role/persistence/RolesPersistenceTest.kt
similarity index 100%
rename from tests/java/com/android/role/persistence/RolesPersistenceTest.kt
rename to tests/apex/java/com/android/role/persistence/RolesPersistenceTest.kt
diff --git a/tests/cts/safetycenter/Android.bp b/tests/cts/safetycenter/Android.bp
new file mode 100644
index 0000000..f3b9716
--- /dev/null
+++ b/tests/cts/safetycenter/Android.bp
@@ -0,0 +1,49 @@
+//
+// Copyright (C) 2021 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "CtsSafetyCenterTestCases",
+    defaults: ["mts-target-sdk-version-current"],
+    sdk_version: "test_current",
+    min_sdk_version: "30",
+    srcs: [
+        "src/**/*.kt",
+    ],
+    static_libs: [
+        "androidx.test.core",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "androidx.test.runner",
+        "androidx.test.uiautomator_uiautomator",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "kotlin-stdlib",
+        "kotlinx-coroutines-android",
+        "kotlin-test",
+        "modules-utils-build_system",
+        "safety-center-resources-lib",
+        "truth-prebuilt",
+    ],
+    test_suites: [
+        "cts",
+        "general-tests",
+        "mts-permission",
+    ],
+}
diff --git a/tests/cts/safetycenter/AndroidManifest.xml b/tests/cts/safetycenter/AndroidManifest.xml
new file mode 100644
index 0000000..4a78475
--- /dev/null
+++ b/tests/cts/safetycenter/AndroidManifest.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 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="android.safetycenter.cts">
+
+    <application>
+        <receiver android:name="android.safetycenter.testing.SafetySourceBroadcastReceiver"
+                  android:exported="false">
+            <intent-filter>
+                <action android:name="android.safetycenter.action.REFRESH_SAFETY_SOURCES"/>
+            </intent-filter>
+        </receiver>
+
+        <uses-library android:name="android.test.runner"/>
+    </application>
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+                     android:label="CTS tests for SafetyCenter"
+                     android:targetPackage="android.safetycenter.cts">
+        <meta-data android:name="listener"
+                   android:value="com.android.cts.runner.CtsTestRunListener"/>
+    </instrumentation>
+
+    <uses-permission android:name="android.permission.DISABLE_KEYGUARD"/>
+
+    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
+</manifest>
diff --git a/tests/cts/safetycenter/AndroidTest.xml b/tests/cts/safetycenter/AndroidTest.xml
new file mode 100644
index 0000000..fc74c4a
--- /dev/null
+++ b/tests/cts/safetycenter/AndroidTest.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<configuration description="Config for CTS SafetyCenter test cases">
+
+    <!-- TODO(b/207111503): Use Sdk33ModuleController once available, and remove @SdkSuppress
+         annotations -->
+    <object
+        class="com.android.tradefed.testtype.suite.module.Sdk31ModuleController"
+        type="module_controller"/>
+
+    <option name="config-descriptor:metadata" key="component" value="framework"/>
+    <option name="config-descriptor:metadata" key="parameter"
+            value="not_instant_app"/>
+    <option name="config-descriptor:metadata" key="parameter"
+            value="not_multi_abi"/>
+    <option name="config-descriptor:metadata" key="parameter"
+            value="secondary_user"/>
+
+    <option name="test-suite-tag" value="cts"/>
+
+    <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+        <option name="force-skip-system-props"
+                value="true"/> <!-- avoid restarting device -->
+    </target_preparer>
+
+    <target_preparer
+        class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="cleanup-apks" value="true"/>
+        <option name="test-file-name" value="CtsSafetyCenterTestCases.apk"/>
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+        <option name="package" value="android.safetycenter.cts"/>
+        <option name="runtime-hint" value="5m"/>
+    </test>
+</configuration>
diff --git a/tests/cts/safetycenter/OWNERS b/tests/cts/safetycenter/OWNERS
new file mode 100644
index 0000000..026d342
--- /dev/null
+++ b/tests/cts/safetycenter/OWNERS
@@ -0,0 +1,3 @@
+# Bug component: 1026964
+
+include platform/frameworks/base:/core/java/android/permission/OWNERS
diff --git a/tests/cts/safetycenter/res/values/strings.xml b/tests/cts/safetycenter/res/values/strings.xml
new file mode 100644
index 0000000..8ba7f04
--- /dev/null
+++ b/tests/cts/safetycenter/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- Test reference -->
+    <string name="reference" translatable="false">Reference</string>
+</resources>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_disabled_no_work.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_disabled_no_work.xml
new file mode 100644
index 0000000..b2594c7
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_disabled_no_work.xml
@@ -0,0 +1,17 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="all_profiles"
+                initialDisplayState="disabled"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_no_work.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_no_work.xml
new file mode 100644
index 0000000..e582e04
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_all_no_work.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="all_profiles"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_summary.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_summary.xml
new file mode 100644
index 0000000..6d06599
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_summary.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                initialDisplayState="disabled"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_title.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_title.xml
new file mode 100644
index 0000000..6d53576
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_disabled_no_title.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                initialDisplayState="disabled"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_duplicate_key.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_duplicate_key.xml
new file mode 100644
index 0000000..be7114d
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_duplicate_key.xml
@@ -0,0 +1,28 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id1"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="id2"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_intent.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_intent.xml
new file mode 100644
index 0000000..b30a6d9
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_intent.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                intentAction="intent"
+                profile="primary_profile_only"
+                initialDisplayState="hidden"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_summary.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_summary.xml
new file mode 100644
index 0000000..c0fd181
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_summary.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                summary="@string/reference"
+                profile="primary_profile_only"
+                initialDisplayState="hidden"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_title.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_title.xml
new file mode 100644
index 0000000..190fcdc
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_hidden_with_title.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                profile="primary_profile_only"
+                initialDisplayState="hidden"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_display.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_display.xml
new file mode 100644
index 0000000..4c1d00a
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_display.xml
@@ -0,0 +1,17 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                initialDisplayState="invalid"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_profile.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_profile.xml
new file mode 100644
index 0000000..303415b
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_invalid_profile.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="invalid"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_id.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_id.xml
new file mode 100644
index 0000000..8ace4ea
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_id.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_intent.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_intent.xml
new file mode 100644
index 0000000..6119e4a
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_intent.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_package.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_package.xml
new file mode 100644
index 0000000..2b40602
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_package.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_profile.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_profile.xml
new file mode 100644
index 0000000..f54d940
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_profile.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_summary.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_summary.xml
new file mode 100644
index 0000000..0683f16
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_summary.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_title.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_title.xml
new file mode 100644
index 0000000..84f90e5
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_no_title.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_hidden_with_work.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_hidden_with_work.xml
new file mode 100644
index 0000000..ad7add5
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_hidden_with_work.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                titleForWork="@string/reference"
+                profile="primary_profile_only"
+                initialDisplayState="hidden"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_with_work.xml b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_with_work.xml
new file mode 100644
index 0000000..3ecf802
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_dynamic_safety_source_primary_with_work.xml
@@ -0,0 +1,17 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                titleForWork="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_duplicate_key.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_duplicate_key.xml
new file mode 100644
index 0000000..9c00d57
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_duplicate_key.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"/>
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_invalid_profile.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_invalid_profile.xml
new file mode 100644
index 0000000..dd85d55
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_invalid_profile.xml
@@ -0,0 +1,11 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="invalid"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_id.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_id.xml
new file mode 100644
index 0000000..d57db8e
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_id.xml
@@ -0,0 +1,10 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                packageName="package"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_package.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_package.xml
new file mode 100644
index 0000000..d68b557
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_package.xml
@@ -0,0 +1,10 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_profile.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_profile.xml
new file mode 100644
index 0000000..7e7b6ef
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_no_profile.xml
@@ -0,0 +1,10 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_display.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_display.xml
new file mode 100644
index 0000000..1fab839
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_display.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"
+                initialDisplayState="disabled"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_intent.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_intent.xml
new file mode 100644
index 0000000..9a7fa6b
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_intent.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"
+                intentAction="intent"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_search.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_search.xml
new file mode 100644
index 0000000..b065a38
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_search.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"
+                searchTerms="@string/reference"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_summary.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_summary.xml
new file mode 100644
index 0000000..7d01001
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_summary.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"
+                summary="@string/reference"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_title.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_title.xml
new file mode 100644
index 0000000..3b46eea
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_title.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="primary_profile_only"
+                title="@string/reference"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_work.xml b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_work.xml
new file mode 100644
index 0000000..e501490
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_issue_only_safety_source_with_work.xml
@@ -0,0 +1,12 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id">
+            <issue-only-safety-source
+                id="id"
+                packageName="package"
+                profile="all_profiles"
+                titleForWork="@string/reference"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_mixed_safety_source_duplicate_key.xml b/tests/cts/safetycenter/res/xml/config_mixed_safety_source_duplicate_key.xml
new file mode 100644
index 0000000..e939a9c
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_mixed_safety_source_duplicate_key.xml
@@ -0,0 +1,26 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id1"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="id2"
+            title="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_reference_invalid.xml b/tests/cts/safetycenter/res/xml/config_reference_invalid.xml
new file mode 100644
index 0000000..7431729
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_reference_invalid.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="title"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="1"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_center_config_missing.xml b/tests/cts/safetycenter/res/xml/config_safety_center_config_missing.xml
new file mode 100644
index 0000000..4757916
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_center_config_missing.xml
@@ -0,0 +1,2 @@
+<other-root>
+</other-root>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_config_empty.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_config_empty.xml
new file mode 100644
index 0000000..b26ffc0
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_config_empty.xml
@@ -0,0 +1,4 @@
+<safety-center-config>
+    <safety-sources-config>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_config_missing.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_config_missing.xml
new file mode 100644
index 0000000..1254575
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_config_missing.xml
@@ -0,0 +1,4 @@
+<safety-center-config>
+    <other-internal-config>
+    </other-internal-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_group_duplicate_id.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_group_duplicate_id.xml
new file mode 100644
index 0000000..0ce7337
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_group_duplicate_id.xml
@@ -0,0 +1,26 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id1"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id2"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_group_empty.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_group_empty.xml
new file mode 100644
index 0000000..a395152
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_group_empty.xml
@@ -0,0 +1,9 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_group_invalid_icon.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_group_invalid_icon.xml
new file mode 100644
index 0000000..42b4c47
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_group_invalid_icon.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference"
+            statelessIconType="invalid">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_id.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_id.xml
new file mode 100644
index 0000000..eaee673
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_id.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_title.xml b/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_title.xml
new file mode 100644
index 0000000..de3ce82
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_safety_sources_group_no_title.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            summary="@string/reference">
+            <dynamic-safety-source
+                id="id"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_duplicate_key.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_duplicate_key.xml
new file mode 100644
index 0000000..4fe5e1e
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_duplicate_key.xml
@@ -0,0 +1,26 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id1"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="id2"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_invalid_profile.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_invalid_profile.xml
new file mode 100644
index 0000000..7c135e7
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_invalid_profile.xml
@@ -0,0 +1,15 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="invalid"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_no_id.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_id.xml
new file mode 100644
index 0000000..7b91934
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_id.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_no_intent.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_intent.xml
new file mode 100644
index 0000000..9e97efb
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_intent.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_no_profile.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_profile.xml
new file mode 100644
index 0000000..32d23c7
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_profile.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_no_summary.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_summary.xml
new file mode 100644
index 0000000..fa92f0b
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_summary.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_no_title.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_title.xml
new file mode 100644
index 0000000..654a486
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_no_title.xml
@@ -0,0 +1,14 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_broadcast.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_broadcast.xml
new file mode 100644
index 0000000..090bd57
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_broadcast.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                broadcastReceiverClassName="broadcast"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_display.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_display.xml
new file mode 100644
index 0000000..69c9ca2
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_display.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                initialDisplayState="disabled"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_logging.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_logging.xml
new file mode 100644
index 0000000..090554b
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_logging.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                loggingAllowed="false"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_package.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_package.xml
new file mode 100644
index 0000000..2a982b4
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_package.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                packageName="package"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_primary_and_work.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_primary_and_work.xml
new file mode 100644
index 0000000..296e2bc
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_primary_and_work.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                titleForWork="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_refresh.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_refresh.xml
new file mode 100644
index 0000000..caaeee6
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_refresh.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                refreshOnPageOpenAllowed="true"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_static_safety_source_with_severity.xml b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_severity.xml
new file mode 100644
index 0000000..a9f5d9b
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_static_safety_source_with_severity.xml
@@ -0,0 +1,16 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="id"
+            title="@string/reference"
+            summary="@string/reference">
+            <static-safety-source
+                id="id"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"
+                maxSeverityLevel="300"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/res/xml/config_valid.xml b/tests/cts/safetycenter/res/xml/config_valid.xml
new file mode 100644
index 0000000..e2f9e77
--- /dev/null
+++ b/tests/cts/safetycenter/res/xml/config_valid.xml
@@ -0,0 +1,97 @@
+<safety-center-config>
+    <safety-sources-config>
+        <safety-sources-group
+            id="dynamic"
+            title="@string/reference"
+            summary="@string/reference"
+            statelessIconType="privacy">
+            <dynamic-safety-source
+                id="dynamic_barebone"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+            <dynamic-safety-source
+                id="dynamic_all_optional"
+                packageName="package"
+                title="@string/reference"
+                titleForWork="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="all_profiles"
+                initialDisplayState="disabled"
+                maxSeverityLevel="300"
+                searchTerms="@string/reference"
+                broadcastReceiverClassName="broadcast"
+                loggingAllowed="false"
+                refreshOnPageOpenAllowed="true"/>
+            <dynamic-safety-source
+                id="dynamic_disabled"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                profile="primary_profile_only"
+                initialDisplayState="disabled"/>
+            <dynamic-safety-source
+                id="dynamic_hidden"
+                packageName="package"
+                profile="all_profiles"
+                initialDisplayState="hidden"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="static"
+            title="@string/reference">
+            <static-safety-source
+                id="static_barebone"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+            <static-safety-source
+                id="static_all_optional"
+                title="@string/reference"
+                titleForWork="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="all_profiles"
+                searchTerms="@string/reference"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="issue_only">
+            <issue-only-safety-source
+                id="issue_only_barebone"
+                packageName="package"
+                profile="primary_profile_only"/>
+            <issue-only-safety-source
+                id="issue_only_all_optional"
+                packageName="package"
+                profile="all_profiles"
+                maxSeverityLevel="300"
+                broadcastReceiverClassName="broadcast"
+                loggingAllowed="false"
+                refreshOnPageOpenAllowed="true"/>
+        </safety-sources-group>
+        <safety-sources-group
+            id="mixed"
+            title="@string/reference">
+            <dynamic-safety-source
+                id="mixed_dynamic_barebone"
+                packageName="package"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+            <issue-only-safety-source
+                id="mixed_issue_only_barebone"
+                packageName="package"
+                profile="primary_profile_only"/>
+            <static-safety-source
+                id="mixed_static_barebone"
+                title="@string/reference"
+                summary="@string/reference"
+                intentAction="intent"
+                profile="primary_profile_only"/>
+        </safety-sources-group>
+    </safety-sources-config>
+</safety-center-config>
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParseExceptionTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParseExceptionTest.kt
new file mode 100644
index 0000000..3a973e3
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParseExceptionTest.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.config.cts
+
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.ParseException
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [ParseException]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class ParseExceptionTest {
+    @Test
+    fun propagatesMessage() {
+        val message = "error message"
+
+        val exception = ParseException(message)
+
+        assertThat(exception).hasMessageThat().isEqualTo(message)
+        assertThat(exception).hasCauseThat().isNull()
+    }
+
+    @Test
+    fun propagatesMessageAndCause() {
+        val message = "error message"
+        val cause = Exception("error message for cause")
+
+        val exception = ParseException(message, cause)
+
+        assertThat(exception).hasMessageThat().isEqualTo(message)
+        assertThat(exception).hasCauseThat().isEqualTo(cause)
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigInvalidTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigInvalidTest.kt
new file mode 100644
index 0000000..354587d
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigInvalidTest.kt
@@ -0,0 +1,385 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.config.cts
+
+import android.content.Context
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.ParseException
+import android.safetycenter.config.SafetyCenterConfig
+import android.safetycenter.cts.R
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runners.Parameterized
+import org.junit.runner.RunWith
+
+@RunWith(Parameterized::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class ParserConfigInvalidTest {
+    private val context: Context = getApplicationContext()
+
+    data class Params(
+        private val testName: String,
+        val configResourceId: Int,
+        val errorMessage: String,
+        val causeErrorMessage: String?
+    ) {
+        override fun toString() = testName
+    }
+
+    @Parameterized.Parameter
+    lateinit var params: Params
+
+    @Test
+    fun invalidConfig_throws() {
+        val parser = context.resources.getXml(params.configResourceId)
+        val thrown = assertThrows(ParseException::class.java) {
+            SafetyCenterConfig.fromXml(parser)
+        }
+        assertThat(thrown).hasMessageThat().isEqualTo(params.errorMessage)
+        if (params.causeErrorMessage != null) {
+            assertThat(thrown.cause).hasMessageThat().isEqualTo(params.causeErrorMessage)
+        }
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun parameters() = arrayOf(
+            Params(
+                "ConfigDynamicSafetySourceAllDisabledNoWork",
+                R.xml.config_dynamic_safety_source_all_disabled_no_work,
+                "Element dynamic-safety-source invalid",
+                "Required attribute titleForWork missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceAllNoWork",
+                R.xml.config_dynamic_safety_source_all_no_work,
+                "Element dynamic-safety-source invalid",
+                "Required attribute titleForWork missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceDisabledNoSummary",
+                R.xml.config_dynamic_safety_source_disabled_no_summary,
+                "Element dynamic-safety-source invalid",
+                "Required attribute summary missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceDisabledNoTitle",
+                R.xml.config_dynamic_safety_source_disabled_no_title,
+                "Element dynamic-safety-source invalid",
+                "Required attribute title missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceDuplicateKey",
+                R.xml.config_dynamic_safety_source_duplicate_key,
+                "Element safety-sources-config invalid",
+                "Duplicate id id among safety sources"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceHiddenWithIntent",
+                R.xml.config_dynamic_safety_source_hidden_with_intent,
+                "Element dynamic-safety-source invalid",
+                "Prohibited attribute intentAction present"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceHiddenWithSummary",
+                R.xml.config_dynamic_safety_source_hidden_with_summary,
+                "Element dynamic-safety-source invalid",
+                "Prohibited attribute summary present"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceHiddenWithTitle",
+                R.xml.config_dynamic_safety_source_hidden_with_title,
+                "Element dynamic-safety-source invalid",
+                "Prohibited attribute title present"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceInvalidDisplay",
+                R.xml.config_dynamic_safety_source_invalid_display,
+                "Attribute dynamic-safety-source.initialDisplayState invalid",
+                null
+            ),
+            Params(
+                "ConfigDynamicSafetySourceInvalidProfile",
+                R.xml.config_dynamic_safety_source_invalid_profile,
+                "Attribute dynamic-safety-source.profile invalid",
+                null
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoId",
+                R.xml.config_dynamic_safety_source_no_id,
+                "Element dynamic-safety-source invalid",
+                "Required attribute id missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoIntent",
+                R.xml.config_dynamic_safety_source_no_intent,
+                "Element dynamic-safety-source invalid",
+                "Required attribute intentAction missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoPackage",
+                R.xml.config_dynamic_safety_source_no_package,
+                "Element dynamic-safety-source invalid",
+                "Required attribute packageName missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoProfile",
+                R.xml.config_dynamic_safety_source_no_profile,
+                "Element dynamic-safety-source invalid",
+                "Required attribute profile missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoSummary",
+                R.xml.config_dynamic_safety_source_no_summary,
+                "Element dynamic-safety-source invalid",
+                "Required attribute summary missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourceNoTitle",
+                R.xml.config_dynamic_safety_source_no_title,
+                "Element dynamic-safety-source invalid",
+                "Required attribute title missing"
+            ),
+            Params(
+                "ConfigDynamicSafetySourcePrimaryHiddenWithWork",
+                R.xml.config_dynamic_safety_source_primary_hidden_with_work,
+                "Element dynamic-safety-source invalid",
+                "Prohibited attribute titleForWork present"
+            ),
+            Params(
+                "ConfigDynamicSafetySourcePrimaryWithWork",
+                R.xml.config_dynamic_safety_source_primary_with_work,
+                "Element dynamic-safety-source invalid",
+                "Prohibited attribute titleForWork present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceDuplicateKey",
+                R.xml.config_issue_only_safety_source_duplicate_key,
+                "Element safety-sources-config invalid",
+                "Duplicate id id among safety sources"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceInvalidProfile",
+                R.xml.config_issue_only_safety_source_invalid_profile,
+                "Attribute issue-only-safety-source.profile invalid",
+                null
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceNoId",
+                R.xml.config_issue_only_safety_source_no_id,
+                "Element issue-only-safety-source invalid",
+                "Required attribute id missing"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceNoPackage",
+                R.xml.config_issue_only_safety_source_no_package,
+                "Element issue-only-safety-source invalid",
+                "Required attribute packageName missing"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceNoProfile",
+                R.xml.config_issue_only_safety_source_no_profile,
+                "Element issue-only-safety-source invalid",
+                "Required attribute profile missing"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithDisplay",
+                R.xml.config_issue_only_safety_source_with_display,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute initialDisplayState present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithIntent",
+                R.xml.config_issue_only_safety_source_with_intent,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute intentAction present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithSearch",
+                R.xml.config_issue_only_safety_source_with_search,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute searchTerms present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithSummary",
+                R.xml.config_issue_only_safety_source_with_summary,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute summary present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithTitle",
+                R.xml.config_issue_only_safety_source_with_title,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute title present"
+            ),
+            Params(
+                "ConfigIssueOnlySafetySourceWithWork",
+                R.xml.config_issue_only_safety_source_with_work,
+                "Element issue-only-safety-source invalid",
+                "Prohibited attribute titleForWork present"
+            ),
+            Params(
+                "ConfigMixedSafetySourceDuplicateKey",
+                R.xml.config_mixed_safety_source_duplicate_key,
+                "Element safety-sources-config invalid",
+                "Duplicate id id among safety sources"
+            ),
+            Params(
+                "ConfigReferenceInvalid",
+                R.xml.config_reference_invalid,
+                "Reference title in safety-sources-group.title missing or invalid",
+                null
+            ),
+            Params(
+                "ConfigSafetyCenterConfigMissing",
+                R.xml.config_safety_center_config_missing,
+                "Element safety-center-config missing",
+                null
+            ),
+            Params(
+                "ConfigSafetySourcesConfigEmpty",
+                R.xml.config_safety_sources_config_empty,
+                "Element safety-sources-config invalid",
+                "No safety sources groups present"
+            ),
+            Params(
+                "ConfigSafetySourcesConfigMissing",
+                R.xml.config_safety_sources_config_missing,
+                "Element safety-sources-config missing",
+                null
+            ),
+            Params(
+                "ConfigSafetySourcesGroupDuplicateId",
+                R.xml.config_safety_sources_group_duplicate_id,
+                "Element safety-sources-config invalid",
+                "Duplicate id id among safety sources groups"
+            ),
+            Params(
+                "ConfigSafetySourcesGroupEmpty",
+                R.xml.config_safety_sources_group_empty,
+                "Element safety-sources-group invalid",
+                "Safety sources group empty"
+            ),
+            Params(
+                "ConfigSafetySourcesGroupInvalidIcon",
+                R.xml.config_safety_sources_group_invalid_icon,
+                "Attribute safety-sources-group.statelessIconType invalid",
+                null
+            ),
+            Params(
+                "ConfigSafetySourcesGroupNoId",
+                R.xml.config_safety_sources_group_no_id,
+                "Element safety-sources-group invalid",
+                "Required attribute id missing"
+            ),
+            Params(
+                "ConfigSafetySourcesGroupNoTitle",
+                R.xml.config_safety_sources_group_no_title,
+                "Element safety-sources-group invalid",
+                "Required attribute title missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceDuplicateKey",
+                R.xml.config_static_safety_source_duplicate_key,
+                "Element safety-sources-config invalid",
+                "Duplicate id id among safety sources"
+            ),
+            Params(
+                "ConfigStaticSafetySourceInvalidProfile",
+                R.xml.config_static_safety_source_invalid_profile,
+                "Attribute static-safety-source.profile invalid",
+                null
+            ),
+            Params(
+                "ConfigStaticSafetySourceNoId",
+                R.xml.config_static_safety_source_no_id,
+                "Element static-safety-source invalid",
+                "Required attribute id missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceNoIntent",
+                R.xml.config_static_safety_source_no_intent,
+                "Element static-safety-source invalid",
+                "Required attribute intentAction missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceNoProfile",
+                R.xml.config_static_safety_source_no_profile,
+                "Element static-safety-source invalid",
+                "Required attribute profile missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceNoSummary",
+                R.xml.config_static_safety_source_no_summary,
+                "Element static-safety-source invalid",
+                "Required attribute summary missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceNoTitle",
+                R.xml.config_static_safety_source_no_title,
+                "Element static-safety-source invalid",
+                "Required attribute title missing"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithBroadcast",
+                R.xml.config_static_safety_source_with_broadcast,
+                "Element static-safety-source invalid",
+                "Prohibited attribute broadcastReceiverClassName present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithDisplay",
+                R.xml.config_static_safety_source_with_display,
+                "Element static-safety-source invalid",
+                "Prohibited attribute initialDisplayState present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithLogging",
+                R.xml.config_static_safety_source_with_logging,
+                "Element static-safety-source invalid",
+                "Prohibited attribute loggingAllowed present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithPackage",
+                R.xml.config_static_safety_source_with_package,
+                "Element static-safety-source invalid",
+                "Prohibited attribute packageName present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithPrimaryAndWork",
+                R.xml.config_static_safety_source_with_primary_and_work,
+                "Element static-safety-source invalid",
+                "Prohibited attribute titleForWork present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithRefresh",
+                R.xml.config_static_safety_source_with_refresh,
+                "Element static-safety-source invalid",
+                "Prohibited attribute refreshOnPageOpenAllowed present"
+            ),
+            Params(
+                "ConfigStaticSafetySourceWithSeverity",
+                R.xml.config_static_safety_source_with_severity,
+                "Element static-safety-source invalid",
+                "Prohibited attribute maxSeverityLevel present"
+            )
+        )
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigValidTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigValidTest.kt
new file mode 100644
index 0000000..87ed2fe
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/ParserConfigValidTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.config.cts
+
+import android.content.Context
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.SafetyCenterConfig
+import android.safetycenter.config.SafetySource
+import android.safetycenter.config.SafetySourcesGroup
+import android.safetycenter.cts.R
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class ParserConfigValidTest {
+    private val context: Context = getApplicationContext()
+
+    @Test
+    fun validConfig_matchesExpected() {
+        val parser = context.resources.getXml(R.xml.config_valid)
+        val expected = SafetyCenterConfig.Builder()
+            .addSafetySourcesGroup(SafetySourcesGroup.Builder()
+                .setId("dynamic")
+                .setTitleResId(R.string.reference)
+                .setSummaryResId(R.string.reference)
+                .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                    .setId("dynamic_barebone")
+                    .setPackageName("package")
+                    .setTitleResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                    .setId("dynamic_all_optional")
+                    .setPackageName("package")
+                    .setTitleResId(R.string.reference)
+                    .setTitleForWorkResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_ALL)
+                    .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+                    .setMaxSeverityLevel(300)
+                    .setSearchTermsResId(R.string.reference)
+                    .setBroadcastReceiverClassName("broadcast")
+                    .setLoggingAllowed(false)
+                    .setRefreshOnPageOpenAllowed(true)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                    .setId("dynamic_disabled")
+                    .setPackageName("package")
+                    .setTitleResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                    .setId("dynamic_hidden")
+                    .setPackageName("package")
+                    .setProfile(SafetySource.PROFILE_ALL)
+                    .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_HIDDEN)
+                    .build())
+                .build())
+            .addSafetySourcesGroup(SafetySourcesGroup.Builder()
+                .setId("static")
+                .setTitleResId(R.string.reference)
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+                    .setId("static_barebone")
+                    .setTitleResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+                    .setId("static_all_optional")
+                    .setTitleResId(R.string.reference)
+                    .setTitleForWorkResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_ALL)
+                    .setSearchTermsResId(R.string.reference)
+                    .build())
+                .build())
+            .addSafetySourcesGroup(SafetySourcesGroup.Builder()
+                .setId("issue_only")
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+                    .setId("issue_only_barebone")
+                    .setPackageName("package")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+                    .setId("issue_only_all_optional")
+                    .setPackageName("package")
+                    .setProfile(SafetySource.PROFILE_ALL)
+                    .setMaxSeverityLevel(300)
+                    .setBroadcastReceiverClassName("broadcast")
+                    .setLoggingAllowed(false)
+                    .setRefreshOnPageOpenAllowed(true)
+                    .build())
+                .build())
+            .addSafetySourcesGroup(SafetySourcesGroup.Builder()
+                .setId("mixed")
+                .setTitleResId(R.string.reference)
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                    .setId("mixed_dynamic_barebone")
+                    .setPackageName("package")
+                    .setTitleResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+                    .setId("mixed_issue_only_barebone")
+                    .setPackageName("package")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .addSafetySource(SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+                    .setId("mixed_static_barebone")
+                    .setTitleResId(R.string.reference)
+                    .setSummaryResId(R.string.reference)
+                    .setIntentAction("intent")
+                    .setProfile(SafetySource.PROFILE_PRIMARY)
+                    .build())
+                .build())
+            .build()
+        assertThat(SafetyCenterConfig.fromXml(parser)).isEqualTo(expected)
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetyCenterConfigTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetyCenterConfigTest.kt
new file mode 100644
index 0000000..c42192e
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetyCenterConfigTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.config.cts
+
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.SafetyCenterConfig
+import android.safetycenter.testing.AnyTester
+import android.safetycenter.testing.ParcelableTester
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetyCenterConfig]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterConfigTest {
+    @Test
+    fun getSafetySources_returnsSafetySources() {
+        assertThat(BASE.safetySourcesGroups)
+            .containsExactly(
+                SafetySourcesGroupTest.RIGID,
+                SafetySourcesGroupTest.HIDDEN
+            ).inOrder()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(BASE.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetyCenterConfig() {
+        ParcelableTester.assertThatRoundTripReturnsOriginal(BASE, SafetyCenterConfig.CREATOR)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_areEqual() {
+        AnyTester.assertThatRepresentationsAreEqual(BASE, BASE)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val baseAlt = SafetyCenterConfig.Builder()
+            .addSafetySourcesGroup(SafetySourcesGroupTest.RIGID)
+            .addSafetySourcesGroup(SafetySourcesGroupTest.HIDDEN)
+            .build()
+        AnyTester.assertThatRepresentationsAreEqual(BASE, baseAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSafetySources_areNotEqual() {
+        val baseAlt = SafetyCenterConfig.Builder()
+            .addSafetySourcesGroup(SafetySourcesGroupTest.HIDDEN)
+            .addSafetySourcesGroup(SafetySourcesGroupTest.RIGID)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(BASE, baseAlt)
+    }
+
+    companion object {
+        private val BASE = SafetyCenterConfig.Builder()
+            .addSafetySourcesGroup(SafetySourcesGroupTest.RIGID)
+            .addSafetySourcesGroup(SafetySourcesGroupTest.HIDDEN)
+            .build()
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourceTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourceTest.kt
new file mode 100644
index 0000000..9970ddd
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourceTest.kt
@@ -0,0 +1,699 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.config.cts
+
+import android.content.res.Resources
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.SafetySource
+import android.safetycenter.testing.AnyTester
+import android.safetycenter.testing.ParcelableTester
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySource]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetySourceTest {
+    @Test
+    fun getType_returnsType() {
+        assertThat(DYNAMIC_BAREBONE.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+        assertThat(DYNAMIC_ALL_OPTIONAL.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+        assertThat(DYNAMIC_HIDDEN.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+        assertThat(DYNAMIC_DISABLED.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+        assertThat(STATIC_BAREBONE.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+        assertThat(STATIC_ALL_OPTIONAL.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+        assertThat(ISSUE_ONLY_BAREBONE.type).isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.type)
+            .isEqualTo(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+    }
+
+    @Test
+    fun getId_returnsId() {
+        assertThat(DYNAMIC_BAREBONE.id).isEqualTo(DYNAMIC_BAREBONE_ID)
+        assertThat(DYNAMIC_ALL_OPTIONAL.id).isEqualTo(DYNAMIC_ALL_OPTIONAL_ID)
+        assertThat(DYNAMIC_HIDDEN.id).isEqualTo(DYNAMIC_HIDDEN_ID)
+        assertThat(DYNAMIC_DISABLED.id).isEqualTo(DYNAMIC_DISABLED_ID)
+        assertThat(STATIC_BAREBONE.id).isEqualTo(STATIC_BAREBONE_ID)
+        assertThat(STATIC_ALL_OPTIONAL.id).isEqualTo(STATIC_ALL_OPTIONAL_ID)
+        assertThat(ISSUE_ONLY_BAREBONE.id).isEqualTo(ISSUE_ONLY_BAREBONE_ID)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.id).isEqualTo(ISSUE_ONLY_ALL_OPTIONAL_ID)
+    }
+
+    @Test
+    fun getPackageName_returnsPackageNameOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.packageName).isEqualTo(PACKAGE_NAME)
+        assertThat(DYNAMIC_ALL_OPTIONAL.packageName).isEqualTo(PACKAGE_NAME)
+        assertThat(DYNAMIC_HIDDEN.packageName).isEqualTo(PACKAGE_NAME)
+        assertThat(DYNAMIC_DISABLED.packageName).isEqualTo(PACKAGE_NAME)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.packageName
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.packageName
+        }
+        assertThat(ISSUE_ONLY_BAREBONE.packageName).isEqualTo(PACKAGE_NAME)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.packageName).isEqualTo(PACKAGE_NAME)
+    }
+
+    @Test
+    fun getTitleResId_returnsTitleResIdOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_ALL_OPTIONAL.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_DISABLED.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_HIDDEN.titleResId).isEqualTo(Resources.ID_NULL)
+        assertThat(STATIC_BAREBONE.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(STATIC_ALL_OPTIONAL.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.titleResId
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.titleResId
+        }
+    }
+
+    @Test
+    fun getTitleForWorkResId_returnsTitleForWorkResIdOrThrows() {
+        assertThrows(UnsupportedOperationException::class.java) {
+            DYNAMIC_BAREBONE.titleForWorkResId
+        }
+        assertThat(DYNAMIC_ALL_OPTIONAL.titleForWorkResId).isEqualTo(REFERENCE_RES_ID)
+        assertThrows(UnsupportedOperationException::class.java) {
+            DYNAMIC_DISABLED.titleForWorkResId
+        }
+        assertThat(DYNAMIC_HIDDEN.titleForWorkResId).isEqualTo(Resources.ID_NULL)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.titleForWorkResId
+        }
+        assertThat(STATIC_ALL_OPTIONAL.titleForWorkResId).isEqualTo(REFERENCE_RES_ID)
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.titleForWorkResId
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.titleForWorkResId
+        }
+    }
+
+    @Test
+    fun getSummaryResId_returnsSummaryResIdOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_ALL_OPTIONAL.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_DISABLED.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_HIDDEN.summaryResId).isEqualTo(Resources.ID_NULL)
+        assertThat(STATIC_BAREBONE.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(STATIC_ALL_OPTIONAL.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.summaryResId
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.summaryResId
+        }
+    }
+
+    @Test
+    fun getIntentAction_returnsIntentActionOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.intentAction).isEqualTo(INTENT_ACTION)
+        assertThat(DYNAMIC_ALL_OPTIONAL.intentAction).isEqualTo(INTENT_ACTION)
+        assertThat(DYNAMIC_DISABLED.intentAction).isNull()
+        assertThat(DYNAMIC_HIDDEN.intentAction).isNull()
+        assertThat(STATIC_BAREBONE.intentAction).isEqualTo(INTENT_ACTION)
+        assertThat(STATIC_ALL_OPTIONAL.intentAction).isEqualTo(INTENT_ACTION)
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.intentAction
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.intentAction
+        }
+    }
+
+    @Test
+    fun getProfile_returnsProfile() {
+        assertThat(DYNAMIC_BAREBONE.profile).isEqualTo(SafetySource.PROFILE_PRIMARY)
+        assertThat(DYNAMIC_ALL_OPTIONAL.profile).isEqualTo(SafetySource.PROFILE_ALL)
+        assertThat(DYNAMIC_DISABLED.profile).isEqualTo(SafetySource.PROFILE_PRIMARY)
+        assertThat(DYNAMIC_HIDDEN.profile).isEqualTo(SafetySource.PROFILE_ALL)
+        assertThat(STATIC_BAREBONE.profile).isEqualTo(SafetySource.PROFILE_PRIMARY)
+        assertThat(STATIC_ALL_OPTIONAL.profile).isEqualTo(SafetySource.PROFILE_ALL)
+        assertThat(ISSUE_ONLY_BAREBONE.profile).isEqualTo(SafetySource.PROFILE_PRIMARY)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.profile).isEqualTo(SafetySource.PROFILE_ALL)
+    }
+
+    @Test
+    fun getInitialDisplayState_returnsInitialDisplayStateOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.initialDisplayState)
+            .isEqualTo(SafetySource.INITIAL_DISPLAY_STATE_ENABLED)
+        assertThat(DYNAMIC_ALL_OPTIONAL.initialDisplayState)
+            .isEqualTo(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+        assertThat(DYNAMIC_DISABLED.initialDisplayState)
+            .isEqualTo(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+        assertThat(DYNAMIC_HIDDEN.initialDisplayState)
+            .isEqualTo(SafetySource.INITIAL_DISPLAY_STATE_HIDDEN)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.initialDisplayState
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.initialDisplayState
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.initialDisplayState
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.initialDisplayState
+        }
+    }
+
+    @Test
+    fun getMaxSeverityLevel_returnsMaxSeverityLevelOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.maxSeverityLevel).isEqualTo(Integer.MAX_VALUE)
+        assertThat(DYNAMIC_ALL_OPTIONAL.maxSeverityLevel).isEqualTo(MAX_SEVERITY_LEVEL)
+        assertThat(DYNAMIC_DISABLED.maxSeverityLevel).isEqualTo(Integer.MAX_VALUE)
+        assertThat(DYNAMIC_HIDDEN.maxSeverityLevel).isEqualTo(Integer.MAX_VALUE)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.maxSeverityLevel
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.maxSeverityLevel
+        }
+        assertThat(ISSUE_ONLY_BAREBONE.maxSeverityLevel).isEqualTo(Integer.MAX_VALUE)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.maxSeverityLevel).isEqualTo(MAX_SEVERITY_LEVEL)
+    }
+
+    @Test
+    fun getSearchTermsResId_returnsSearchTermsResIdOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.searchTermsResId).isEqualTo(Resources.ID_NULL)
+        assertThat(DYNAMIC_ALL_OPTIONAL.searchTermsResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(DYNAMIC_DISABLED.searchTermsResId).isEqualTo(Resources.ID_NULL)
+        assertThat(DYNAMIC_HIDDEN.searchTermsResId).isEqualTo(Resources.ID_NULL)
+        assertThat(STATIC_BAREBONE.searchTermsResId).isEqualTo(Resources.ID_NULL)
+        assertThat(STATIC_ALL_OPTIONAL.searchTermsResId).isEqualTo(REFERENCE_RES_ID)
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_BAREBONE.searchTermsResId
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            ISSUE_ONLY_ALL_OPTIONAL.searchTermsResId
+        }
+    }
+
+    @Test
+    fun getBroadcastReceiverClassName_returnsBroadcastReceiverClassNameOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.broadcastReceiverClassName).isNull()
+        assertThat(DYNAMIC_ALL_OPTIONAL.broadcastReceiverClassName)
+            .isEqualTo(BROADCAST_RECEIVER_CLASS_NAME)
+        assertThat(DYNAMIC_DISABLED.broadcastReceiverClassName).isNull()
+        assertThat(DYNAMIC_HIDDEN.broadcastReceiverClassName).isNull()
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.broadcastReceiverClassName
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.broadcastReceiverClassName
+        }
+        assertThat(ISSUE_ONLY_BAREBONE.broadcastReceiverClassName).isNull()
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.broadcastReceiverClassName)
+            .isEqualTo(BROADCAST_RECEIVER_CLASS_NAME)
+    }
+
+    @Test
+    fun isLoggingAllowed_returnsLoggingAllowedOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.isLoggingAllowed).isEqualTo(true)
+        assertThat(DYNAMIC_ALL_OPTIONAL.isLoggingAllowed).isEqualTo(false)
+        assertThat(DYNAMIC_DISABLED.isLoggingAllowed).isEqualTo(true)
+        assertThat(DYNAMIC_HIDDEN.isLoggingAllowed).isEqualTo(true)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.isLoggingAllowed
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.isLoggingAllowed
+        }
+        assertThat(ISSUE_ONLY_BAREBONE.isLoggingAllowed).isEqualTo(true)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.isLoggingAllowed).isEqualTo(false)
+    }
+
+    @Test
+    fun isRefreshOnPageOpenAllowed_returnsRefreshOnPageOpenAllowedOrThrows() {
+        assertThat(DYNAMIC_BAREBONE.isRefreshOnPageOpenAllowed).isEqualTo(false)
+        assertThat(DYNAMIC_ALL_OPTIONAL.isRefreshOnPageOpenAllowed).isEqualTo(true)
+        assertThat(DYNAMIC_DISABLED.isRefreshOnPageOpenAllowed).isEqualTo(false)
+        assertThat(DYNAMIC_HIDDEN.isRefreshOnPageOpenAllowed).isEqualTo(false)
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_BAREBONE.isRefreshOnPageOpenAllowed
+        }
+        assertThrows(UnsupportedOperationException::class.java) {
+            STATIC_ALL_OPTIONAL.isRefreshOnPageOpenAllowed
+        }
+        assertThat(ISSUE_ONLY_BAREBONE.isRefreshOnPageOpenAllowed).isEqualTo(false)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.isRefreshOnPageOpenAllowed).isEqualTo(true)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(DYNAMIC_BAREBONE.describeContents()).isEqualTo(0)
+        assertThat(DYNAMIC_ALL_OPTIONAL.describeContents()).isEqualTo(0)
+        assertThat(DYNAMIC_HIDDEN.describeContents()).isEqualTo(0)
+        assertThat(DYNAMIC_DISABLED.describeContents()).isEqualTo(0)
+        assertThat(STATIC_BAREBONE.describeContents()).isEqualTo(0)
+        assertThat(STATIC_ALL_OPTIONAL.describeContents()).isEqualTo(0)
+        assertThat(ISSUE_ONLY_BAREBONE.describeContents()).isEqualTo(0)
+        assertThat(ISSUE_ONLY_ALL_OPTIONAL.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySource() {
+        ParcelableTester.assertThatRoundTripReturnsOriginal(DYNAMIC_BAREBONE, SafetySource.CREATOR)
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            DYNAMIC_ALL_OPTIONAL,
+            SafetySource.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(DYNAMIC_HIDDEN, SafetySource.CREATOR)
+        ParcelableTester.assertThatRoundTripReturnsOriginal(DYNAMIC_DISABLED, SafetySource.CREATOR)
+        ParcelableTester.assertThatRoundTripReturnsOriginal(STATIC_BAREBONE, SafetySource.CREATOR)
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            STATIC_ALL_OPTIONAL,
+            SafetySource.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            ISSUE_ONLY_BAREBONE,
+            SafetySource.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            ISSUE_ONLY_ALL_OPTIONAL,
+            SafetySource.CREATOR
+        )
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_areEqual() {
+        AnyTester.assertThatRepresentationsAreEqual(DYNAMIC_BAREBONE, DYNAMIC_BAREBONE)
+        AnyTester.assertThatRepresentationsAreEqual(DYNAMIC_ALL_OPTIONAL, DYNAMIC_ALL_OPTIONAL)
+        AnyTester.assertThatRepresentationsAreEqual(DYNAMIC_HIDDEN, DYNAMIC_HIDDEN)
+        AnyTester.assertThatRepresentationsAreEqual(DYNAMIC_DISABLED, DYNAMIC_DISABLED)
+        AnyTester.assertThatRepresentationsAreEqual(STATIC_BAREBONE, STATIC_BAREBONE)
+        AnyTester.assertThatRepresentationsAreEqual(STATIC_ALL_OPTIONAL, STATIC_ALL_OPTIONAL)
+        AnyTester.assertThatRepresentationsAreEqual(ISSUE_ONLY_BAREBONE, ISSUE_ONLY_BAREBONE)
+        AnyTester.assertThatRepresentationsAreEqual(
+            ISSUE_ONLY_ALL_OPTIONAL,
+            ISSUE_ONLY_ALL_OPTIONAL
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val dynamicAllOptionalCopy = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalCopy)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTypes_areNotEqual() {
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_BAREBONE, STATIC_BAREBONE)
+        AnyTester.assertThatRepresentationsAreNotEqual(STATIC_BAREBONE, ISSUE_ONLY_BAREBONE)
+        AnyTester.assertThatRepresentationsAreNotEqual(ISSUE_ONLY_BAREBONE, DYNAMIC_BAREBONE)
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, STATIC_ALL_OPTIONAL)
+        AnyTester.assertThatRepresentationsAreNotEqual(STATIC_ALL_OPTIONAL, ISSUE_ONLY_ALL_OPTIONAL)
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            ISSUE_ONLY_ALL_OPTIONAL,
+            DYNAMIC_ALL_OPTIONAL
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId("other")
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentPackageNames_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName("other")
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTitleResIds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(-1)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTitleForWorkResIds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(-1)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSummaryResIds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(-1)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIntentActions_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction("other")
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentProfiles_areNotEqual() {
+        val dynamicHiddenAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_HIDDEN_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setProfile(SafetySource.PROFILE_PRIMARY)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_HIDDEN)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_HIDDEN, dynamicHiddenAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentInitialDisplayStates_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_ENABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentMaxSeverityLevel_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(-1)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSearchTermsResIds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(-1)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentBroadcastReceiverClassNames_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName("other")
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentLoggingAlloweds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(true)
+            .setRefreshOnPageOpenAllowed(true)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentRefreshOnPageOpenAlloweds_areNotEqual() {
+        val dynamicAllOptionalAlt = SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+            .setId(DYNAMIC_ALL_OPTIONAL_ID)
+            .setPackageName(PACKAGE_NAME)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setTitleForWorkResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setIntentAction(INTENT_ACTION)
+            .setProfile(SafetySource.PROFILE_ALL)
+            .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+            .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+            .setSearchTermsResId(REFERENCE_RES_ID)
+            .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+            .setLoggingAllowed(false)
+            .setRefreshOnPageOpenAllowed(false)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(DYNAMIC_ALL_OPTIONAL, dynamicAllOptionalAlt)
+    }
+
+    companion object {
+        private const val PACKAGE_NAME = "package"
+        private const val REFERENCE_RES_ID = 9999
+        private const val INTENT_ACTION = "intent"
+        private const val BROADCAST_RECEIVER_CLASS_NAME = "broadcast"
+        private const val MAX_SEVERITY_LEVEL = 300
+
+        private const val DYNAMIC_BAREBONE_ID = "dynamic_barebone"
+        private const val DYNAMIC_ALL_OPTIONAL_ID = "dynamic_all_optional"
+        private const val DYNAMIC_DISABLED_ID = "dynamic_disabled"
+        private const val DYNAMIC_HIDDEN_ID = "dynamic_hidden"
+        private const val STATIC_BAREBONE_ID = "static_barebone"
+        private const val STATIC_ALL_OPTIONAL_ID = "static_all_optional"
+        private const val ISSUE_ONLY_BAREBONE_ID = "issue_only_barebone"
+        private const val ISSUE_ONLY_ALL_OPTIONAL_ID = "issue_only_all_optional"
+
+        internal val DYNAMIC_BAREBONE =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                .setId(DYNAMIC_BAREBONE_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setTitleResId(REFERENCE_RES_ID)
+                .setSummaryResId(REFERENCE_RES_ID)
+                .setIntentAction(INTENT_ACTION)
+                .setProfile(SafetySource.PROFILE_PRIMARY)
+                .build()
+
+        private val DYNAMIC_ALL_OPTIONAL =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                .setId(DYNAMIC_ALL_OPTIONAL_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setTitleResId(REFERENCE_RES_ID)
+                .setTitleForWorkResId(REFERENCE_RES_ID)
+                .setSummaryResId(REFERENCE_RES_ID)
+                .setIntentAction(INTENT_ACTION)
+                .setProfile(SafetySource.PROFILE_ALL)
+                .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+                .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+                .setSearchTermsResId(REFERENCE_RES_ID)
+                .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+                .setLoggingAllowed(false)
+                .setRefreshOnPageOpenAllowed(true)
+                .build()
+
+        private val DYNAMIC_DISABLED =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                .setId(DYNAMIC_DISABLED_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setTitleResId(REFERENCE_RES_ID)
+                .setSummaryResId(REFERENCE_RES_ID)
+                .setProfile(SafetySource.PROFILE_PRIMARY)
+                .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_DISABLED)
+                .build()
+
+        private val DYNAMIC_HIDDEN =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC)
+                .setId(DYNAMIC_HIDDEN_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setProfile(SafetySource.PROFILE_ALL)
+                .setInitialDisplayState(SafetySource.INITIAL_DISPLAY_STATE_HIDDEN)
+                .build()
+
+        internal val STATIC_BAREBONE =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+                .setId(STATIC_BAREBONE_ID)
+                .setTitleResId(REFERENCE_RES_ID)
+                .setSummaryResId(REFERENCE_RES_ID)
+                .setIntentAction(INTENT_ACTION)
+                .setProfile(SafetySource.PROFILE_PRIMARY)
+                .build()
+
+        private val STATIC_ALL_OPTIONAL =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_STATIC)
+                .setId(STATIC_ALL_OPTIONAL_ID)
+                .setTitleResId(REFERENCE_RES_ID)
+                .setTitleForWorkResId(REFERENCE_RES_ID)
+                .setSummaryResId(REFERENCE_RES_ID)
+                .setIntentAction(INTENT_ACTION)
+                .setProfile(SafetySource.PROFILE_ALL)
+                .setSearchTermsResId(REFERENCE_RES_ID)
+                .build()
+
+        internal val ISSUE_ONLY_BAREBONE =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+                .setId(ISSUE_ONLY_BAREBONE_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setProfile(SafetySource.PROFILE_PRIMARY)
+                .build()
+
+        private val ISSUE_ONLY_ALL_OPTIONAL =
+            SafetySource.Builder(SafetySource.SAFETY_SOURCE_TYPE_ISSUE_ONLY)
+                .setId(ISSUE_ONLY_ALL_OPTIONAL_ID)
+                .setPackageName(PACKAGE_NAME)
+                .setProfile(SafetySource.PROFILE_ALL)
+                .setMaxSeverityLevel(MAX_SEVERITY_LEVEL)
+                .setBroadcastReceiverClassName(BROADCAST_RECEIVER_CLASS_NAME)
+                .setLoggingAllowed(false)
+                .setRefreshOnPageOpenAllowed(true)
+                .build()
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourcesGroupTest.kt b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourcesGroupTest.kt
new file mode 100644
index 0000000..09c0de9
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/config/cts/SafetySourcesGroupTest.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.config.cts
+
+import android.content.res.Resources
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.SafetySourcesGroup
+import android.safetycenter.testing.AnyTester
+import android.safetycenter.testing.ParcelableTester
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySourcesGroup]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetySourcesGroupTest {
+    @Test
+    fun getType_returnsType() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.type)
+            .isEqualTo(SafetySourcesGroup.SAFETY_SOURCES_GROUP_TYPE_COLLAPSIBLE)
+        assertThat(COLLAPSIBLE_WITH_ICON.type)
+            .isEqualTo(SafetySourcesGroup.SAFETY_SOURCES_GROUP_TYPE_COLLAPSIBLE)
+        assertThat(COLLAPSIBLE_WITH_BOTH.type)
+            .isEqualTo(SafetySourcesGroup.SAFETY_SOURCES_GROUP_TYPE_COLLAPSIBLE)
+        assertThat(RIGID.type).isEqualTo(SafetySourcesGroup.SAFETY_SOURCES_GROUP_TYPE_RIGID)
+        assertThat(HIDDEN.type).isEqualTo(SafetySourcesGroup.SAFETY_SOURCES_GROUP_TYPE_HIDDEN)
+    }
+
+    @Test
+    fun getId_returnsId() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.id).isEqualTo(COLLAPSIBLE_WITH_SUMMARY_ID)
+        assertThat(COLLAPSIBLE_WITH_ICON.id).isEqualTo(COLLAPSIBLE_WITH_ICON_ID)
+        assertThat(COLLAPSIBLE_WITH_BOTH.id).isEqualTo(COLLAPSIBLE_WITH_BOTH_ID)
+        assertThat(RIGID.id).isEqualTo(RIGID_ID)
+        assertThat(HIDDEN.id).isEqualTo(HIDDEN_ID)
+    }
+
+    @Test
+    fun getTitleResId_returnsTitleResId() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(COLLAPSIBLE_WITH_ICON.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(COLLAPSIBLE_WITH_BOTH.titleResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(RIGID.titleResId).isEqualTo(REFERENCE_RES_ID)
+        // This is not an enforced invariant, titleResId should just be ignored for hidden groups
+        assertThat(HIDDEN.titleResId).isEqualTo(Resources.ID_NULL)
+    }
+
+    @Test
+    fun getSummaryResId_returnsSummaryResId() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(COLLAPSIBLE_WITH_ICON.summaryResId).isEqualTo(Resources.ID_NULL)
+        assertThat(COLLAPSIBLE_WITH_BOTH.summaryResId).isEqualTo(REFERENCE_RES_ID)
+        assertThat(RIGID.summaryResId).isEqualTo(Resources.ID_NULL)
+        // This is not an enforced invariant, summaryResId should just be ignored for hidden groups
+        assertThat(HIDDEN.summaryResId).isEqualTo(Resources.ID_NULL)
+    }
+
+    @Test
+    fun getStatelessIconType_returnsStatelessIconType() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.statelessIconType)
+            .isEqualTo(SafetySourcesGroup.STATELESS_ICON_TYPE_NONE)
+        assertThat(COLLAPSIBLE_WITH_ICON.statelessIconType)
+            .isEqualTo(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+        assertThat(COLLAPSIBLE_WITH_BOTH.statelessIconType)
+            .isEqualTo(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+        assertThat(RIGID.statelessIconType).isEqualTo(SafetySourcesGroup.STATELESS_ICON_TYPE_NONE)
+        // This is not an enforced invariant
+        // statelessIconType should just be ignored for hidden groups
+        assertThat(HIDDEN.statelessIconType).isEqualTo(SafetySourcesGroup.STATELESS_ICON_TYPE_NONE)
+    }
+
+    @Test
+    fun getSafetySources_returnsSafetySources() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.safetySources)
+            .containsExactly(SafetySourceTest.DYNAMIC_BAREBONE)
+        assertThat(COLLAPSIBLE_WITH_ICON.safetySources)
+            .containsExactly(SafetySourceTest.STATIC_BAREBONE)
+        assertThat(COLLAPSIBLE_WITH_BOTH.safetySources)
+            .containsExactly(
+                SafetySourceTest.DYNAMIC_BAREBONE,
+                SafetySourceTest.STATIC_BAREBONE,
+                SafetySourceTest.ISSUE_ONLY_BAREBONE
+            ).inOrder()
+        assertThat(RIGID.safetySources).containsExactly(SafetySourceTest.STATIC_BAREBONE)
+        assertThat(HIDDEN.safetySources).containsExactly(SafetySourceTest.ISSUE_ONLY_BAREBONE)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(COLLAPSIBLE_WITH_SUMMARY.describeContents()).isEqualTo(0)
+        assertThat(COLLAPSIBLE_WITH_ICON.describeContents()).isEqualTo(0)
+        assertThat(COLLAPSIBLE_WITH_BOTH.describeContents()).isEqualTo(0)
+        assertThat(RIGID.describeContents()).isEqualTo(0)
+        assertThat(HIDDEN.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySourcesGroup() {
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            COLLAPSIBLE_WITH_SUMMARY,
+            SafetySourcesGroup.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            COLLAPSIBLE_WITH_ICON,
+            SafetySourcesGroup.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(
+            COLLAPSIBLE_WITH_BOTH,
+            SafetySourcesGroup.CREATOR
+        )
+        ParcelableTester.assertThatRoundTripReturnsOriginal(RIGID, SafetySourcesGroup.CREATOR)
+        ParcelableTester.assertThatRoundTripReturnsOriginal(HIDDEN, SafetySourcesGroup.CREATOR)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_areEqual() {
+        AnyTester.assertThatRepresentationsAreEqual(
+            COLLAPSIBLE_WITH_SUMMARY,
+            COLLAPSIBLE_WITH_SUMMARY
+        )
+        AnyTester.assertThatRepresentationsAreEqual(COLLAPSIBLE_WITH_ICON, COLLAPSIBLE_WITH_ICON)
+        AnyTester.assertThatRepresentationsAreEqual(COLLAPSIBLE_WITH_BOTH, COLLAPSIBLE_WITH_BOTH)
+        AnyTester.assertThatRepresentationsAreEqual(RIGID, RIGID)
+        AnyTester.assertThatRepresentationsAreEqual(HIDDEN, HIDDEN)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val collapsibleWithBothCopy = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .addSafetySource(SafetySourceTest.STATIC_BAREBONE)
+            .addSafetySource(SafetySourceTest.ISSUE_ONLY_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreEqual(COLLAPSIBLE_WITH_BOTH, collapsibleWithBothCopy)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTypes_areNotEqual() {
+        AnyTester.assertThatRepresentationsAreNotEqual(COLLAPSIBLE_WITH_BOTH, RIGID)
+        AnyTester.assertThatRepresentationsAreNotEqual(RIGID, HIDDEN)
+        AnyTester.assertThatRepresentationsAreNotEqual(HIDDEN, COLLAPSIBLE_WITH_BOTH)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIds_areNotEqual() {
+        val collapsibleWithBothAlt = SafetySourcesGroup.Builder()
+            .setId("other")
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            COLLAPSIBLE_WITH_BOTH,
+            collapsibleWithBothAlt
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTitleResIds_areNotEqual() {
+        val collapsibleWithBothAlt = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(-1)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            COLLAPSIBLE_WITH_BOTH,
+            collapsibleWithBothAlt
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSummaryResIds_areNotEqual() {
+        val collapsibleWithBothAlt = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(-1)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            COLLAPSIBLE_WITH_BOTH,
+            collapsibleWithBothAlt
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentInitialDisplayStates_areNotEqual() {
+        val collapsibleWithBothAlt = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_NONE)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            COLLAPSIBLE_WITH_BOTH,
+            collapsibleWithBothAlt
+        )
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSafetySources_areNotEqual() {
+        val collapsibleWithBothAlt = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.STATIC_BAREBONE)
+            .build()
+        AnyTester.assertThatRepresentationsAreNotEqual(
+            COLLAPSIBLE_WITH_BOTH,
+            collapsibleWithBothAlt
+        )
+    }
+
+    companion object {
+        private const val REFERENCE_RES_ID = 9999
+
+        private const val COLLAPSIBLE_WITH_SUMMARY_ID = "collapsible_with_summary"
+        private const val COLLAPSIBLE_WITH_ICON_ID = "collapsible_with_icon"
+        private const val COLLAPSIBLE_WITH_BOTH_ID = "collapsible_with_both"
+        private const val RIGID_ID = "rigid"
+        private const val HIDDEN_ID = "hidden"
+
+        private val COLLAPSIBLE_WITH_SUMMARY = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_SUMMARY_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .build()
+
+        private val COLLAPSIBLE_WITH_ICON = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_ICON_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.STATIC_BAREBONE)
+            .build()
+
+        private val COLLAPSIBLE_WITH_BOTH = SafetySourcesGroup.Builder()
+            .setId(COLLAPSIBLE_WITH_BOTH_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .setSummaryResId(REFERENCE_RES_ID)
+            .setStatelessIconType(SafetySourcesGroup.STATELESS_ICON_TYPE_PRIVACY)
+            .addSafetySource(SafetySourceTest.DYNAMIC_BAREBONE)
+            .addSafetySource(SafetySourceTest.STATIC_BAREBONE)
+            .addSafetySource(SafetySourceTest.ISSUE_ONLY_BAREBONE)
+            .build()
+
+        internal val RIGID = SafetySourcesGroup.Builder()
+            .setId(RIGID_ID)
+            .setTitleResId(REFERENCE_RES_ID)
+            .addSafetySource(SafetySourceTest.STATIC_BAREBONE)
+            .build()
+
+        internal val HIDDEN = SafetySourcesGroup.Builder()
+            .setId(HIDDEN_ID)
+            .addSafetySource(SafetySourceTest.ISSUE_ONLY_BAREBONE)
+            .build()
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterActivityTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterActivityTest.kt
new file mode 100644
index 0000000..57a1c2c
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterActivityTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_SAFETY_CENTER
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.testing.SafetyCenterFlags
+import android.safetycenter.testing.SafetyCenterFlags.deviceSupportsSafetyCenter
+import android.support.test.uiautomator.By
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.android.compatibility.common.util.UiAutomatorUtils.waitFindObject
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterActivityTest {
+    private val context: Context = getApplicationContext()
+
+    @Before
+    fun assumeDeviceSupportsSafetyCenterToRunTests() {
+        assumeTrue(context.deviceSupportsSafetyCenter())
+    }
+
+    @Test
+    fun launchActivity_withFlagEnabled_showsSecurityAndPrivacyTitle() {
+        SafetyCenterFlags.setSafetyCenterEnabled(true)
+
+        startSafetyCenterActivity()
+
+        // CollapsingToolbar title can't be found by text, so using description instead.
+        waitFindObject(By.desc("Security & Privacy"))
+    }
+
+    @Test
+    fun launchActivity_withFlagDisabled_showsSecurityTitle() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+
+        startSafetyCenterActivity()
+
+        // CollapsingToolbar title can't be found by text, so using description instead.
+        waitFindObject(By.desc("Security"))
+    }
+
+    private fun startSafetyCenterActivity() {
+        context.startActivity(
+            Intent(ACTION_SAFETY_CENTER)
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+        )
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterDataTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterDataTest.kt
new file mode 100644
index 0000000..5d3df89
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterDataTest.kt
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterData
+import android.safetycenter.SafetyCenterEntry
+import android.safetycenter.SafetyCenterEntryGroup
+import android.safetycenter.SafetyCenterEntryOrGroup
+import android.safetycenter.SafetyCenterIssue
+import android.safetycenter.SafetyCenterStaticEntry
+import android.safetycenter.SafetyCenterStaticEntryGroup
+import android.safetycenter.SafetyCenterStatus
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterDataTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    val status1 = SafetyCenterStatus.Builder()
+            .setTitle("This is my title")
+            .setSummary("This is my summary")
+            .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_RECOMMENDATION)
+            .build()
+    val status2 = SafetyCenterStatus.Builder()
+            .setTitle("This is also my title")
+            .setSummary("This is also my summary")
+            .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+            .build()
+
+    val issue1 = SafetyCenterIssue.Builder("iSsUe_iD_oNe")
+            .setTitle("An issue title")
+            .setSummary("An issue summary")
+            .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK)
+            .build()
+    val issue2 = SafetyCenterIssue.Builder("iSsUe_iD_tWo")
+            .setTitle("Another issue title")
+            .setSummary("Another issue summary")
+            .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_RECOMMENDATION)
+            .build()
+
+    val entry1 = SafetyCenterEntry.Builder("eNtRy_iD_OnE")
+            .setTitle("An entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+            .build()
+    val entry2 = SafetyCenterEntry.Builder("eNtRy_iD_TwO")
+            .setTitle("Another entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .build()
+
+    val entryGroup1 = SafetyCenterEntryGroup.Builder("eNtRy_gRoUp_iD")
+            .setTitle("An entry group title")
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .setEntries(listOf(entry2))
+            .build()
+
+    val entryOrGroup1 = SafetyCenterEntryOrGroup(entry1)
+    val entryOrGroup2 = SafetyCenterEntryOrGroup(entryGroup1)
+
+    val staticEntry1 = SafetyCenterStaticEntry(
+            "A static entry title",
+            "A static entry summary",
+            pendingIntent)
+    val staticEntry2 = SafetyCenterStaticEntry(
+            "Another static entry title",
+            "Another static entry summary",
+            pendingIntent)
+
+    val staticEntryGroup1 = SafetyCenterStaticEntryGroup(
+            "A static entry group title", listOf(staticEntry1))
+    val staticEntryGroup2 = SafetyCenterStaticEntryGroup(
+            "Another static entry group title", listOf(staticEntry2))
+
+    val data1 = SafetyCenterData(
+            status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+    val data2 = SafetyCenterData(
+            status2, listOf(issue2), listOf(entryOrGroup2), listOf(staticEntryGroup2))
+
+    @Test
+    fun getStatus_returnsStatus() {
+        assertThat(data1.status).isEqualTo(status1)
+        assertThat(data2.status).isEqualTo(status2)
+    }
+
+    @Test
+    fun getIssues_returnsIssues() {
+        assertThat(data1.issues).containsExactly(issue1)
+        assertThat(data2.issues).containsExactly(issue2)
+    }
+
+    @Test
+    fun getIssues_mutationsAreNotReflected() {
+        val mutatedIssues = data1.issues
+        mutatedIssues.add(issue2)
+
+        assertThat(mutatedIssues).containsExactly(issue1, issue2)
+        assertThat(data1.issues).doesNotContain(issue2)
+    }
+
+    @Test
+    fun getEntriesOrGroups_returnsEntriesOrGroups() {
+        assertThat(data1.entriesOrGroups).containsExactly(entryOrGroup1)
+        assertThat(data2.entriesOrGroups).containsExactly(entryOrGroup2)
+    }
+
+    @Test
+    fun getEntriesOrGroups_mutationsAreNotReflected() {
+        val mutatedEntriesOrGroups = data1.entriesOrGroups
+        mutatedEntriesOrGroups.add(entryOrGroup2)
+
+        assertThat(mutatedEntriesOrGroups).containsExactly(entryOrGroup1, entryOrGroup2)
+        assertThat(data1.entriesOrGroups).doesNotContain(entryOrGroup2)
+    }
+
+    @Test
+    fun getStaticEntryGroups_returnsStaticEntryGroups() {
+        assertThat(data1.staticEntryGroups).containsExactly(staticEntryGroup1)
+        assertThat(data2.staticEntryGroups).containsExactly(staticEntryGroup2)
+    }
+
+    @Test
+    fun getStaticEntryGroups_mutationsAreNotReflected() {
+        val mutatedStaticEntryGroups = data1.staticEntryGroups
+        mutatedStaticEntryGroups.add(staticEntryGroup2)
+
+        assertThat(mutatedStaticEntryGroups).containsExactly(staticEntryGroup1, staticEntryGroup2)
+        assertThat(data1.staticEntryGroups).doesNotContain(staticEntryGroup2)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(data1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel: Parcel = Parcel.obtain()
+
+        data1.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterData.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(data1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(data1).isEqualTo(data1)
+        assertThat(data1.hashCode()).isEqualTo(data1.hashCode())
+        assertThat(data1.toString()).isEqualTo(data1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val data = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+        val equivalentData = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+
+        assertThat(data).isEqualTo(equivalentData)
+        assertThat(data.hashCode()).isEqualTo(equivalentData.hashCode())
+        assertThat(data.toString()).isEqualTo(equivalentData.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_withEmptyLists_equalByValue_areEqual() {
+        val data = SafetyCenterData(status1, listOf(), listOf(), listOf())
+        val equivalentData = SafetyCenterData(status1, listOf(), listOf(), listOf())
+
+        assertThat(data).isEqualTo(equivalentData)
+        assertThat(data.hashCode()).isEqualTo(equivalentData.hashCode())
+        assertThat(data.toString()).isEqualTo(equivalentData.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentStatuses_areNotEqual() {
+        val data = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+        val differentData = SafetyCenterData(
+                status2, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+
+        assertThat(data).isNotEqualTo(differentData)
+        assertThat(data.toString()).isNotEqualTo(differentData.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentIssues_areNotEqual() {
+        val data = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+        val differentData = SafetyCenterData(
+                status1, listOf(issue2), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+
+        assertThat(data).isNotEqualTo(differentData)
+        assertThat(data.toString()).isNotEqualTo(differentData.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEntriesOrGroups_areNotEqual() {
+        val data = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+        val differentData = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup2), listOf(staticEntryGroup1))
+
+        assertThat(data).isNotEqualTo(differentData)
+        assertThat(data.toString()).isNotEqualTo(differentData.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentStaticEntryGroups_areNotEqual() {
+        val data = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup1))
+        val differentData = SafetyCenterData(
+                status1, listOf(issue1), listOf(entryOrGroup1), listOf(staticEntryGroup2))
+
+        assertThat(data).isNotEqualTo(differentData)
+        assertThat(data.toString()).isNotEqualTo(differentData.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryGroupTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryGroupTest.kt
new file mode 100644
index 0000000..4140e40
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryGroupTest.kt
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterEntry
+import android.safetycenter.SafetyCenterEntryGroup
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterEntryGroupTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    val entry1 = SafetyCenterEntry.Builder("eNtRy_iD_OnE")
+            .setTitle("An entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+            .build()
+    val entry2 = SafetyCenterEntry.Builder("eNtRy_iD_TwO")
+            .setTitle("Another entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .build()
+
+    val groupId1 = "gRoUp_iD_oNe"
+    val groupId2 = "gRoUp_iD_tWo"
+
+    val entryGroup1 = SafetyCenterEntryGroup.Builder(groupId1)
+            .setTitle("A group title")
+            .setSummary("A group summary")
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+            .setEntries(listOf(entry1))
+            .build()
+    val entryGroup2 = SafetyCenterEntryGroup.Builder(groupId2)
+            .setTitle("Another group title")
+            .setSummary("Another group summary")
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .setEntries(listOf(entry2))
+            .build()
+
+    @Test
+    fun getId_returnsId() {
+        assertThat(entryGroup1.id).isEqualTo(groupId1)
+        assertThat(entryGroup2.id).isEqualTo(groupId2)
+    }
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1).setTitle("title one").build().title)
+                .isEqualTo("title one")
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1).setTitle("title two").build().title)
+                .isEqualTo("title two")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1).setSummary("one").build().summary)
+                .isEqualTo("one")
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1).setSummary("two").build().summary)
+                .isEqualTo("two")
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1).setSummary(null).build().summary)
+                .isNull()
+    }
+
+    @Test
+    fun getSeverityLevel_returnsSeverityLevel() {
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_NONE)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_NONE)
+    }
+
+    @Test
+    fun getSeverityNoneIconType_returnsSeverityNoneIconType() {
+        assertThat(entryGroup1.severityNoneIconType)
+                .isEqualTo(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_NO_ICON)
+        assertThat(SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .build()
+                .severityNoneIconType)
+                .isEqualTo(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+    }
+
+    @Test
+    fun getEntries_returnsEntries() {
+        assertThat(entryGroup1.entries).containsExactly(entry1)
+        assertThat(entryGroup2.entries).containsExactly(entry2)
+    }
+
+    @Test
+    fun getEntries_mutationsAreNotReflected() {
+        val mutatedEntries = entryGroup1.entries
+        mutatedEntries.add(entry2)
+
+        assertThat(mutatedEntries).containsExactly(entry1, entry2)
+        assertThat(entryGroup1.entries).doesNotContain(entry2)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(entryGroup1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        entryGroup1.writeToParcel(parcel, /* flags= */ 0)
+        parcel.setDataPosition(0)
+
+        val fromParcel = SafetyCenterEntryGroup.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(entryGroup1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(entryGroup1).isEqualTo(entryGroup1)
+        assertThat(entryGroup1.hashCode()).isEqualTo(entryGroup1.hashCode())
+        assertThat(entryGroup1.toString()).isEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val entry = SafetyCenterEntryGroup.Builder(groupId1)
+                .setTitle("A group title")
+                .setSummary("A group summary")
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+                .setEntries(listOf(entry1))
+                .build()
+        val equivalentEntry = SafetyCenterEntryGroup.Builder(groupId1)
+                .setTitle("A group title")
+                .setSummary("A group summary")
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+                .setEntries(listOf(entry1))
+                .build()
+
+        assertThat(entry).isEqualTo(equivalentEntry)
+        assertThat(entry.hashCode()).isEqualTo(equivalentEntry.hashCode())
+        assertThat(entry.toString()).isEqualTo(equivalentEntry.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_fromCopyBuilder_areEqual() {
+        val equivalentToEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1).build()
+
+        assertThat(equivalentToEntryGroup1).isEqualTo(entryGroup1)
+        assertThat(equivalentToEntryGroup1.hashCode()).isEqualTo(entryGroup1.hashCode())
+        assertThat(equivalentToEntryGroup1.toString()).isEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentIds_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setId("different!")
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentTitles_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setTitle("different!")
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSummaries_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSummary("different!")
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSeverityLevels_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_UNKNOWN)
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSeverityNoneIconTypes_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEntries_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setEntries(listOf(entry2))
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEntries_emptyList_areNotEqual() {
+        val differentFromEntryGroup1 = SafetyCenterEntryGroup.Builder(entryGroup1)
+                .setEntries(listOf())
+                .build()
+
+        assertThat(differentFromEntryGroup1).isNotEqualTo(entryGroup1)
+        assertThat(differentFromEntryGroup1.toString()).isNotEqualTo(entryGroup1.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryOrGroupTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryOrGroupTest.kt
new file mode 100644
index 0000000..193643b
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryOrGroupTest.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterEntry
+import android.safetycenter.SafetyCenterEntryGroup
+import android.safetycenter.SafetyCenterEntryOrGroup
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterEntryOrGroupTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    val entry1 = SafetyCenterEntry.Builder("eNtRy_iD_OnE")
+            .setTitle("An entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+            .build()
+    val entry2 = SafetyCenterEntry.Builder("eNtRy_iD_TwO")
+            .setTitle("Another entry title")
+            .setPendingIntent(pendingIntent)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .build()
+
+    val entryGroup1 = SafetyCenterEntryGroup.Builder("gRoUp_iD_oNe")
+            .setTitle("A group title")
+            .setSummary("A group summary")
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+            .setEntries(listOf(entry1))
+            .build()
+    val entryGroup2 = SafetyCenterEntryGroup.Builder("gRoUp_iD_tWo")
+            .setTitle("Another group title")
+            .setSummary("Another group summary")
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+            .setEntries(listOf(entry2))
+            .build()
+
+    val entryOrGroupWithEntry = SafetyCenterEntryOrGroup(entry1)
+    val entryOrGroupWithGroup = SafetyCenterEntryOrGroup(entryGroup1)
+
+    @Test
+    fun getEntry_returnsEntry() {
+        assertThat(entryOrGroupWithEntry.entry).isEqualTo(entry1)
+    }
+
+    @Test
+    fun getEntry_returnsEntry_whenNull() {
+        assertThat(entryOrGroupWithGroup.entry).isNull()
+    }
+
+    @Test
+    fun getEntryGroup_returnsEntryGroup() {
+        assertThat(entryOrGroupWithGroup.entryGroup).isEqualTo(entryGroup1)
+    }
+
+    @Test
+    fun getEntryGroup_returnsEntryGroup_whenNull() {
+        assertThat(entryOrGroupWithEntry.entryGroup).isNull()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(entryOrGroupWithEntry.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_withEntry_returnsEquivalentObject() {
+        runCreateFromParcel_withWriteToParcel_withGroup_returnsEquivalentObjectTest(
+                entryOrGroupWithEntry)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_withGroup_returnsEquivalentObject() {
+        runCreateFromParcel_withWriteToParcel_withGroup_returnsEquivalentObjectTest(
+                entryOrGroupWithGroup)
+    }
+
+    fun runCreateFromParcel_withWriteToParcel_withGroup_returnsEquivalentObjectTest(
+        entryOrGroup: SafetyCenterEntryOrGroup
+    ) {
+        val parcel: Parcel = Parcel.obtain()
+
+        entryOrGroup.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterEntryOrGroup.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(entryOrGroup)
+    }
+
+    @Test
+    fun equals_hashCode_toString_whenEqualByReference_areEqual() {
+        assertThat(entryOrGroupWithEntry).isEqualTo(entryOrGroupWithEntry)
+        assertThat(entryOrGroupWithEntry.hashCode()).isEqualTo(entryOrGroupWithEntry.hashCode())
+        assertThat(entryOrGroupWithEntry.toString()).isEqualTo(entryOrGroupWithEntry.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_whenEqualByValue_withEntry_areEqual() {
+        val entryOrGroup = SafetyCenterEntryOrGroup(entry1)
+        val equivalentEntryOrGroup = SafetyCenterEntryOrGroup(entry1)
+
+        assertThat(entryOrGroup).isEqualTo(equivalentEntryOrGroup)
+        assertThat(entryOrGroup.hashCode()).isEqualTo(equivalentEntryOrGroup.hashCode())
+        assertThat(entryOrGroup.toString()).isEqualTo(equivalentEntryOrGroup.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_whenEqualByValue_withGroup_areEqual() {
+        val entryOrGroup = SafetyCenterEntryOrGroup(entryGroup1)
+        val equivalentEntryOrGroup = SafetyCenterEntryOrGroup(entryGroup1)
+
+        assertThat(entryOrGroup).isEqualTo(equivalentEntryOrGroup)
+        assertThat(entryOrGroup.hashCode()).isEqualTo(equivalentEntryOrGroup.hashCode())
+        assertThat(entryOrGroup.toString()).isEqualTo(equivalentEntryOrGroup.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEntryAndGroup_areNotEqual() {
+        assertThat(entryOrGroupWithEntry).isNotEqualTo(entryOrGroupWithGroup)
+        assertThat(entryOrGroupWithEntry.toString()).isNotEqualTo(entryOrGroupWithGroup.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEntries_areNotEqual() {
+        val entryOrGroup = SafetyCenterEntryOrGroup(entry1)
+        val differentEntryOrGroup = SafetyCenterEntryOrGroup(entry2)
+
+        assertThat(entryOrGroup).isNotEqualTo(differentEntryOrGroup)
+        assertThat(entryOrGroup.toString()).isNotEqualTo(differentEntryOrGroup.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentGroups_areNotEqual() {
+        val entryOrGroup = SafetyCenterEntryOrGroup(entryGroup1)
+        val differentEntryOrGroup = SafetyCenterEntryOrGroup(entryGroup2)
+
+        assertThat(entryOrGroup).isNotEqualTo(differentEntryOrGroup)
+        assertThat(entryOrGroup.toString()).isNotEqualTo(differentEntryOrGroup.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryTest.kt
new file mode 100644
index 0000000..94fea00
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterEntryTest.kt
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterEntry
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterEntryTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent1 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+    private val pendingIntent2 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Different Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    private val iconAction1 = SafetyCenterEntry.IconAction(
+            SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+    private val iconAction2 = SafetyCenterEntry.IconAction(
+            SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO, pendingIntent2)
+
+    private val entry1 = SafetyCenterEntry.Builder("eNtRy_iD")
+            .setTitle("a title")
+            .setSummary("a summary")
+            .setPendingIntent(pendingIntent1)
+            .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_UNKNOWN)
+            .setIconAction(iconAction1)
+            .build()
+
+    @Test
+    fun getId_returnsId() {
+        assertThat(SafetyCenterEntry.Builder(entry1).setId("id_one").build().id)
+                .isEqualTo("id_one")
+        assertThat(SafetyCenterEntry.Builder(entry1).setId("id_two").build().id)
+                .isEqualTo("id_two")
+    }
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(SafetyCenterEntry.Builder(entry1).setTitle("a title").build().title)
+                .isEqualTo("a title")
+        assertThat(SafetyCenterEntry.Builder(entry1).setTitle("another title").build().title)
+                .isEqualTo("another title")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        assertThat(SafetyCenterEntry.Builder(entry1).setSummary("a summary").build().summary)
+                .isEqualTo("a summary")
+        assertThat(SafetyCenterEntry.Builder(entry1).setSummary("another summary").build().summary)
+                .isEqualTo("another summary")
+        assertThat(SafetyCenterEntry.Builder(entry1).setSummary(null).build().summary)
+                .isNull()
+    }
+
+    @Test
+    fun getSeverityLevel_returnsSeverityLevel() {
+        assertThat(SafetyCenterEntry.Builder(entry1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
+        assertThat(SafetyCenterEntry.Builder(entry1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+    }
+
+    @Test
+    fun getSeverityNoneIconType_returnsSeverityNoneIconType() {
+        assertThat(entry1.severityNoneIconType).isEqualTo(
+                SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_NO_ICON)
+        assertThat(SafetyCenterEntry.Builder(entry1)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .build()
+                .severityNoneIconType)
+                .isEqualTo(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+    }
+
+    @Test
+    fun isEnabled_returnsIsEnabled() {
+        assertThat(SafetyCenterEntry.Builder(entry1).setEnabled(true).build().isEnabled)
+                .isTrue()
+        assertThat(SafetyCenterEntry.Builder(entry1).setEnabled(false).build().isEnabled)
+                .isFalse()
+    }
+
+    @Test
+    fun isEnabled_defaultTrue() {
+        assertThat(SafetyCenterEntry.Builder("eNtRy_iD")
+                .setTitle("a title")
+                .setPendingIntent(pendingIntent1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_UNKNOWN)
+                .build()
+                .isEnabled)
+                .isTrue()
+    }
+
+    @Test
+    fun getPendingIntent_returnsPendingIntent() {
+        assertThat(SafetyCenterEntry.Builder(entry1)
+                .setPendingIntent(pendingIntent1)
+                .build()
+                .pendingIntent)
+                .isEqualTo(pendingIntent1)
+        assertThat(SafetyCenterEntry.Builder(entry1)
+                .setPendingIntent(pendingIntent2)
+                .build()
+                .pendingIntent)
+                .isEqualTo(pendingIntent2)
+    }
+
+    @Test
+    fun getIconAction_returnsIconAction() {
+        assertThat(SafetyCenterEntry.Builder(entry1).setIconAction(iconAction1).build().iconAction)
+                .isEqualTo(iconAction1)
+        assertThat(SafetyCenterEntry.Builder(entry1).setIconAction(iconAction2).build().iconAction)
+                .isEqualTo(iconAction2)
+        assertThat(SafetyCenterEntry.Builder(entry1).setIconAction(null).build().iconAction)
+                .isNull()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(entry1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        entry1.writeToParcel(parcel, /* flags= */ 0)
+        parcel.setDataPosition(0)
+
+        val fromParcel = SafetyCenterEntry.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(entry1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(entry1).isEqualTo(entry1)
+        assertThat(entry1.hashCode()).isEqualTo(entry1.hashCode())
+        assertThat(entry1.toString()).isEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val entry = SafetyCenterEntry.Builder("id")
+                .setTitle("a title")
+                .setSummary("a summary")
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .setPendingIntent(pendingIntent1)
+                .setIconAction(SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO, pendingIntent2)
+                .build()
+        val equivalentEntry = SafetyCenterEntry.Builder("id")
+                .setTitle("a title")
+                .setSummary("a summary")
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_OK)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .setPendingIntent(pendingIntent1)
+                .setIconAction(SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO, pendingIntent2)
+                .build()
+
+        assertThat(entry).isEqualTo(equivalentEntry)
+        assertThat(entry.hashCode()).isEqualTo(equivalentEntry.hashCode())
+        assertThat(entry.toString()).isEqualTo(equivalentEntry.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_fromCopyBuilder_areEqual() {
+        val copyOfEntry1 = SafetyCenterEntry.Builder(entry1).build()
+
+        assertThat(copyOfEntry1).isEqualTo(entry1)
+        assertThat(copyOfEntry1.hashCode()).isEqualTo(entry1.hashCode())
+        assertThat(copyOfEntry1.toString()).isEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentIds_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setId("a different id")
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentTitles_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setTitle("a different title")
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSummaries_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setSummary("a different summary")
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSeverityLevels_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setSeverityLevel(SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_CRITICAL_WARNING)
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSeverityNoneIconTypes_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setSeverityNoneIconType(SafetyCenterEntry.SEVERITY_NONE_ICON_TYPE_PRIVACY)
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentEnabledValues_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setEnabled(false)
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentPendingIntents_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setPendingIntent(pendingIntent2)
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentIconActions_areNotEqual() {
+        val differentFromEntry1 = SafetyCenterEntry.Builder(entry1)
+                .setIconAction(iconAction2)
+                .build()
+
+        assertThat(differentFromEntry1).isNotEqualTo(entry1)
+        assertThat(differentFromEntry1.toString()).isNotEqualTo(entry1.toString())
+    }
+
+    @Test
+    fun iconAction_getType_returnsType() {
+        assertThat(SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+                .type)
+                .isEqualTo(SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR)
+        assertThat(SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO, pendingIntent1)
+                .type)
+                .isEqualTo(SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO)
+    }
+
+    @Test
+    fun iconAction_getPendingIntent_returnsPendingIntent() {
+        assertThat(SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+                .pendingIntent)
+                .isEqualTo(pendingIntent1)
+        assertThat(SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent2)
+                .pendingIntent)
+                .isEqualTo(pendingIntent2)
+    }
+
+    @Test
+    fun iconAction_describeContents_returns0() {
+        assertThat(iconAction1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun iconAction_createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        iconAction1.writeToParcel(parcel, /* flags= */ 0)
+        parcel.setDataPosition(0)
+
+        val fromParcel = SafetyCenterEntry.IconAction.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(iconAction1)
+    }
+
+    @Test
+    fun iconAction_equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(iconAction1).isEqualTo(iconAction1)
+        assertThat(iconAction1.hashCode()).isEqualTo(iconAction1.hashCode())
+        assertThat(iconAction1.toString()).isEqualTo(iconAction1.toString())
+    }
+
+    @Test
+    fun iconAction_equals_hashCode_toString_equalByValue_areEqual() {
+        val iconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+        val equivalentIconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+
+        assertThat(iconAction).isEqualTo(equivalentIconAction)
+        assertThat(iconAction.hashCode()).isEqualTo(equivalentIconAction.hashCode())
+        assertThat(iconAction.toString()).isEqualTo(equivalentIconAction.toString())
+    }
+
+    @Test
+    fun iconAction_equals_toString_differentTypes_areNotEqual() {
+        val iconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+        val differentIconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_INFO, pendingIntent1)
+
+        assertThat(iconAction).isNotEqualTo(differentIconAction)
+        assertThat(iconAction.toString()).isNotEqualTo(differentIconAction.toString())
+    }
+
+    @Test
+    fun intentAction_equals_toString_differentPendingIntents_areNotEqual() {
+        val iconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent1)
+        val differentIconAction = SafetyCenterEntry.IconAction(
+                SafetyCenterEntry.IconAction.ICON_ACTION_TYPE_GEAR, pendingIntent2)
+
+        assertThat(iconAction).isNotEqualTo(differentIconAction)
+        assertThat(iconAction.toString()).isNotEqualTo(differentIconAction.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterErrorDetailsTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterErrorDetailsTest.kt
new file mode 100644
index 0000000..e3e5d9c
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterErrorDetailsTest.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterErrorDetails
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterErrorDetailsTest {
+
+    val errorDetails1 = SafetyCenterErrorDetails("an error message")
+    val errorDetails2 = SafetyCenterErrorDetails("another error message")
+
+    @Test
+    fun getErrorMessage_returnsErrorMessage() {
+        assertThat(errorDetails1.errorMessage).isEqualTo("an error message")
+        assertThat(errorDetails2.errorMessage).isEqualTo("another error message")
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(errorDetails1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        errorDetails1.writeToParcel(parcel, /* flags= */ 0)
+        parcel.setDataPosition(0)
+
+        val fromParcel = SafetyCenterErrorDetails.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(errorDetails1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(errorDetails1).isEqualTo(errorDetails1)
+        assertThat(errorDetails1.hashCode()).isEqualTo(errorDetails1.hashCode())
+        assertThat(errorDetails1.toString()).isEqualTo(errorDetails1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val errorDetails = SafetyCenterErrorDetails("an error message")
+        val equivalentErrorDetails = SafetyCenterErrorDetails("an error message")
+
+        assertThat(errorDetails).isEqualTo(equivalentErrorDetails)
+        assertThat(errorDetails.hashCode()).isEqualTo(equivalentErrorDetails.hashCode())
+        assertThat(errorDetails.toString()).isEqualTo(equivalentErrorDetails.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentErrorMessages_areNotEqual() {
+        val errorDetails = SafetyCenterErrorDetails("an error message")
+        val differentErrorDetails = SafetyCenterErrorDetails("a different error message")
+
+        assertThat(errorDetails).isNotEqualTo(differentErrorDetails)
+        assertThat(errorDetails.toString()).isNotEqualTo(differentErrorDetails.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterIssueTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterIssueTest.kt
new file mode 100644
index 0000000..a842804
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterIssueTest.kt
@@ -0,0 +1,503 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterIssue
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterIssueTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent1 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+    private val pendingIntent2 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Different Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    val action1 = SafetyCenterIssue.Action.Builder("action_id_1")
+            .setLabel("an action")
+            .setPendingIntent(pendingIntent1)
+            .setResolving(true)
+            .setInFlight(true)
+            .setSuccessMessage("a success message")
+            .build()
+    val action2 = SafetyCenterIssue.Action.Builder("action_id_2")
+            .setLabel("another action")
+            .setPendingIntent(pendingIntent2)
+            .setResolving(false)
+            .setInFlight(false)
+            .build()
+
+    val issue1 = SafetyCenterIssue.Builder("issue_id")
+            .setTitle("Everything's good")
+            .setSubtitle("In the neighborhood")
+            .setSummary("Please acknowledge this")
+            .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK)
+            .setDismissible(true)
+            .setShouldConfirmDismissal(true)
+            .setActions(listOf(action1))
+            .build()
+
+    val issueWithRequiredFieldsOnly = SafetyCenterIssue.Builder("issue_id")
+            .setTitle("Everything's good")
+            .setSummary("Please acknowledge this")
+            .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK)
+            .build()
+
+    @Test
+    fun getId_returnsId() {
+        assertThat(SafetyCenterIssue.Builder(issue1).setId("an id").build().id)
+                .isEqualTo("an id")
+        assertThat(SafetyCenterIssue.Builder(issue1).setId("another id").build().id)
+                .isEqualTo("another id")
+    }
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(SafetyCenterIssue.Builder(issue1).setTitle("a title").build().title)
+                .isEqualTo("a title")
+        assertThat(SafetyCenterIssue.Builder(issue1).setTitle("another title").build().title)
+                .isEqualTo("another title")
+    }
+
+    @Test
+    fun getSubtitle_returnsSubtitle() {
+        assertThat(SafetyCenterIssue.Builder(issue1).setSubtitle("a subtitle").build().subtitle)
+                .isEqualTo("a subtitle")
+        assertThat(
+                SafetyCenterIssue.Builder(issue1).setSubtitle("another subtitle").build().subtitle)
+                .isEqualTo("another subtitle")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        assertThat(SafetyCenterIssue.Builder(issue1).setSummary("a summary").build().summary)
+                .isEqualTo("a summary")
+        assertThat(SafetyCenterIssue.Builder(issue1).setSummary("another summary").build().summary)
+                .isEqualTo("another summary")
+    }
+
+    @Test
+    fun getSeverityLevel_returnsSeverityLevel() {
+        assertThat(SafetyCenterIssue.Builder(issue1)
+                .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_RECOMMENDATION)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_RECOMMENDATION)
+        assertThat(SafetyCenterIssue.Builder(issue1)
+                .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_CRITICAL_WARNING)
+                .build()
+                .severityLevel)
+                .isEqualTo(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_CRITICAL_WARNING)
+    }
+
+    @Test
+    fun isDismissible_returnsIsDismissible() {
+        assertThat(SafetyCenterIssue.Builder(issue1).setDismissible(true).build().isDismissible)
+                .isTrue()
+        assertThat(SafetyCenterIssue.Builder(issue1).setDismissible(false).build().isDismissible)
+                .isFalse()
+    }
+
+    @Test
+    fun isDismissible_defaultsToTrue() {
+        assertThat(issueWithRequiredFieldsOnly.isDismissible).isTrue()
+    }
+
+    @Test
+    fun shouldConfirmDismissal_returnsShouldConfirmDismissal() {
+        assertThat(SafetyCenterIssue.Builder(issue1)
+                .setShouldConfirmDismissal(true)
+                .build()
+                .shouldConfirmDismissal())
+                .isTrue()
+        assertThat(SafetyCenterIssue.Builder(issue1)
+                .setShouldConfirmDismissal(false)
+                .build()
+                .shouldConfirmDismissal())
+                .isFalse()
+    }
+
+    @Test
+    fun shouldConfirmDismissal_defaultsToTrue() {
+        assertThat(issueWithRequiredFieldsOnly.shouldConfirmDismissal()).isTrue()
+    }
+
+    @Test
+    fun getActions_returnsActions() {
+        assertThat(SafetyCenterIssue.Builder(issue1)
+                .setActions(listOf(action1, action2))
+                .build().actions)
+                .containsExactly(action1, action2)
+        assertThat(SafetyCenterIssue.Builder(issue1).setActions(listOf(action2)).build().actions)
+                .containsExactly(action2)
+        assertThat(SafetyCenterIssue.Builder(issue1).setActions(listOf()).build().actions)
+                .isEmpty()
+    }
+
+    @Test
+    fun getActions_mutationsAreNotReflected() {
+        val mutatedActions = issue1.actions
+        mutatedActions.add(action2)
+
+        assertThat(mutatedActions).containsExactly(action1, action2)
+        assertThat(issue1.actions).doesNotContain(action2)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(issue1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        issue1.writeToParcel(parcel, /* flags= */ 0)
+        parcel.setDataPosition(0)
+
+        val fromParcel = SafetyCenterIssue.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(issue1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(issue1).isEqualTo(issue1)
+        assertThat(issue1.hashCode()).isEqualTo(issue1.hashCode())
+        assertThat(issue1.toString()).isEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val issue = SafetyCenterIssue.Builder("an id")
+                .setTitle("a title")
+                .setSubtitle("In the neighborhood")
+                .setSummary("Please acknowledge this")
+                .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK)
+                .setActions(listOf(action1))
+                .build()
+        val equivalentIssue = SafetyCenterIssue.Builder("an id")
+                .setTitle("a title")
+                .setSubtitle("In the neighborhood")
+                .setSummary("Please acknowledge this")
+                .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK)
+                .setActions(listOf(action1))
+                .build()
+
+        assertThat(issue).isEqualTo(equivalentIssue)
+        assertThat(issue.hashCode()).isEqualTo(equivalentIssue.hashCode())
+        assertThat(issue.toString()).isEqualTo(equivalentIssue.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_fromCopyBuilder() {
+        val copyOfIssue1 = SafetyCenterIssue.Builder(issue1).build()
+
+        assertThat(copyOfIssue1).isEqualTo(issue1)
+        assertThat(copyOfIssue1.hashCode()).isEqualTo(issue1.hashCode())
+        assertThat(copyOfIssue1.toString()).isEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentIds_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setId("a different id")
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentTitles_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setTitle("a different title")
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentSubtitles_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setSubtitle("a different subtitle")
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentSummaries_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setSummary("a different summary")
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentSeverityLevels_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setSeverityLevel(SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_CRITICAL_WARNING)
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentIsDismissibleValues_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setDismissible(false)
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentShouldConfirmDismissalValues_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setShouldConfirmDismissal(false)
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun equals_toString_differentActions_areNotEqual() {
+        val differentFromIssue1 = SafetyCenterIssue.Builder(issue1)
+                .setActions(listOf(action2))
+                .build()
+
+        assertThat(differentFromIssue1).isNotEqualTo(issue1)
+        assertThat(differentFromIssue1.toString()).isNotEqualTo(issue1.toString())
+    }
+
+    @Test
+    fun action_getId_returnsId() {
+        assertThat(action1.id).isEqualTo("action_id_1")
+        assertThat(action2.id).isEqualTo("action_id_2")
+    }
+
+    @Test
+    fun action_getLabel_returnsLabel() {
+        assertThat(action1.label).isEqualTo("an action")
+        assertThat(action2.label).isEqualTo("another action")
+    }
+
+    @Test
+    fun action_getPendingIntent_returnsPendingIntent() {
+        assertThat(action1.pendingIntent).isEqualTo(pendingIntent1)
+        assertThat(action2.pendingIntent).isEqualTo(pendingIntent2)
+    }
+
+    @Test
+    fun action_isResolving_returnsIsResolving() {
+        assertThat(action1.isResolving).isTrue()
+        assertThat(action2.isResolving).isFalse()
+    }
+
+    @Test
+    fun action_isInFlight_returnsIsInFlight() {
+        assertThat(action1.isInFlight).isTrue()
+        assertThat(action2.isInFlight).isFalse()
+    }
+
+    @Test
+    fun action_getSuccessMessage_returnsSuccessMessage() {
+        assertThat(action1.successMessage).isEqualTo("a success message")
+        assertThat(action2.successMessage).isNull()
+    }
+
+    @Test
+    fun action_describeContents_returns0() {
+        assertThat(action1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun action_createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel = Parcel.obtain()
+
+        action1.writeToParcel(parcel, /* flags= */ 0)
+
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterIssue.Action.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(action1)
+    }
+
+    @Test
+    fun action_equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(action1).isEqualTo(action1)
+        assertThat(action1.hashCode()).isEqualTo(action1.hashCode())
+        assertThat(action1.toString()).isEqualTo(action1.toString())
+    }
+
+    @Test
+    fun action_equals_hashCode_toString_equalByValue_areEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(true)
+                .setInFlight(true)
+                .setSuccessMessage("a success message")
+                .build()
+        val equivalentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(true)
+                .setInFlight(true)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isEqualTo(equivalentAction)
+        assertThat(action.toString()).isEqualTo(equivalentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentIds_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("a_different_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentLabels_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a different label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentPendingIntents_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent2)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentResovlingValues_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(true)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(false)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentInFlightValues_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(true)
+                .setInFlight(true)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setResolving(true)
+                .setInFlight(false)
+                .setSuccessMessage("a success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+
+    @Test
+    fun action_equals_toString_differentSuccessMessages_areNotEqual() {
+        val action = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a success message")
+                .build()
+        val differentAction = SafetyCenterIssue.Action.Builder("an_id")
+                .setLabel("a label")
+                .setPendingIntent(pendingIntent1)
+                .setSuccessMessage("a different success message")
+                .build()
+
+        assertThat(action).isNotEqualTo(differentAction)
+        assertThat(action.toString()).isNotEqualTo(differentAction.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterManagerTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterManagerTest.kt
new file mode 100644
index 0000000..a574a78
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterManagerTest.kt
@@ -0,0 +1,750 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.Manifest.permission.MANAGE_SAFETY_CENTER
+import android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_SAFETY_CENTER
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.SafetyCenterData
+import android.safetycenter.SafetyCenterManager
+import android.safetycenter.SafetyCenterManager.OnSafetyCenterDataChangedListener
+import android.safetycenter.SafetyCenterManager.REFRESH_REASON_PAGE_OPEN
+import android.safetycenter.SafetyCenterManager.REFRESH_REASON_RESCAN_BUTTON_CLICK
+import android.safetycenter.SafetyCenterStatus
+import android.safetycenter.SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN
+import android.safetycenter.SafetyEvent
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED
+import android.safetycenter.SafetySourceData
+import android.safetycenter.SafetySourceErrorDetails
+import android.safetycenter.SafetySourceIssue
+import android.safetycenter.SafetySourceIssue.SEVERITY_LEVEL_CRITICAL_WARNING
+import android.safetycenter.SafetySourceStatus
+import android.safetycenter.SafetySourceStatus.STATUS_LEVEL_CRITICAL_WARNING
+import android.safetycenter.SafetySourceStatus.STATUS_LEVEL_NONE
+import android.safetycenter.SafetySourceStatus.STATUS_LEVEL_OK
+import android.safetycenter.config.SafetyCenterConfig
+import android.safetycenter.config.SafetySource
+import android.safetycenter.config.SafetySource.SAFETY_SOURCE_TYPE_DYNAMIC
+import android.safetycenter.config.SafetySourcesGroup
+import android.safetycenter.testing.SafetyCenterFlags
+import android.safetycenter.testing.SafetyCenterFlags.deviceSupportsSafetyCenter
+import android.safetycenter.testing.SafetySourceBroadcastReceiver
+import android.safetycenter.testing.addOnSafetyCenterDataChangedListenerWithPermission
+import android.safetycenter.testing.clearAllSafetySourceDataWithPermission
+import android.safetycenter.testing.clearSafetyCenterConfigOverrideWithPermission
+import android.safetycenter.testing.getSafetyCenterDataWithPermission
+import android.safetycenter.testing.getSafetySourceDataWithPermission
+import android.safetycenter.testing.isSafetyCenterEnabledWithPermission
+import android.safetycenter.testing.refreshSafetySourcesWithPermission
+import android.safetycenter.testing.removeOnSafetyCenterDataChangedListenerWithPermission
+import android.safetycenter.testing.setSafetyCenterConfigOverrideWithPermission
+import android.safetycenter.testing.setSafetySourceDataWithPermission
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.time.Duration
+import kotlin.test.assertFailsWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterManagerTest {
+    private val context: Context = getApplicationContext()
+    private val safetyCenterManager = context.getSystemService(SafetyCenterManager::class.java)!!
+    private val somePendingIntent =
+        PendingIntent.getActivity(
+            context,
+            0 /* requestCode */,
+            Intent(ACTION_SAFETY_CENTER),
+            FLAG_IMMUTABLE
+        )
+    private val safetySourceDataNone =
+        SafetySourceData.Builder()
+            .setStatus(
+                SafetySourceStatus.Builder(
+                    "None title",
+                    "None summary",
+                    STATUS_LEVEL_NONE,
+                    somePendingIntent
+                )
+                    .build()
+            )
+            .build()
+    private val safetySourceDataOk =
+        SafetySourceData.Builder()
+            .setStatus(
+                SafetySourceStatus.Builder("Ok title", "Ok summary", STATUS_LEVEL_OK,
+                    somePendingIntent)
+                    .build()
+            )
+            .build()
+    private val safetySourceDataCritical =
+        SafetySourceData.Builder()
+            .setStatus(
+                SafetySourceStatus.Builder(
+                    "Critical title",
+                    "Critical summary",
+                    STATUS_LEVEL_CRITICAL_WARNING,
+                    somePendingIntent
+                )
+                    .build()
+            )
+            .addIssue(
+                SafetySourceIssue.Builder(
+                    "critical_issue_id",
+                    "Critical issue title",
+                    "Critical issue summary",
+                    SEVERITY_LEVEL_CRITICAL_WARNING,
+                    "issue_type_id"
+                )
+                    .addAction(
+                        SafetySourceIssue.Action.Builder("critical_action_id", "Solve issue",
+                            somePendingIntent)
+                            .build()
+                    )
+                    .build()
+            )
+            .build()
+    private val listenerChannel = Channel<SafetyCenterData>()
+
+    // The lambda has to be wrapped to the right type because kotlin wraps lambdas in a new Java
+    // functional interface implementation each time they are referenced/cast to a Java interface:
+    // b/215569072.
+    private val listener = OnSafetyCenterDataChangedListener {
+        runBlockingWithTimeout { listenerChannel.send(it) }
+    }
+
+    @Before
+    fun assumeDeviceSupportsSafetyCenterToRunTests() {
+        assumeTrue(context.deviceSupportsSafetyCenter())
+    }
+
+    @Before
+    @After
+    fun clearDataBetweenTest() {
+        SafetyCenterFlags.setSafetyCenterEnabled(true)
+        safetyCenterManager.removeOnSafetyCenterDataChangedListenerWithPermission(listener)
+        safetyCenterManager.clearAllSafetySourceDataWithPermission()
+        safetyCenterManager.clearSafetyCenterConfigOverrideWithPermission()
+        SafetySourceBroadcastReceiver.reset()
+    }
+
+    @After
+    fun cancelChannelAfterTest() {
+        listenerChannel.cancel()
+    }
+
+    @Test
+    fun isSafetyCenterEnabled_withFlagEnabled_returnsTrue() {
+        val isSafetyCenterEnabled = safetyCenterManager.isSafetyCenterEnabledWithPermission()
+
+        assertThat(isSafetyCenterEnabled).isTrue()
+    }
+
+    @Test
+    fun isSafetyCenterEnabled_withFlagDisabled_returnsFalse() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+
+        val isSafetyCenterEnabled = safetyCenterManager.isSafetyCenterEnabledWithPermission()
+
+        assertThat(isSafetyCenterEnabled).isFalse()
+    }
+
+    @Test
+    fun isSafetyCenterEnabled_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) { safetyCenterManager.isSafetyCenterEnabled }
+    }
+
+    @Test
+    fun setSafetySourceData_validId_setsValue() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataNone,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isEqualTo(safetySourceDataNone)
+    }
+
+    @Test
+    fun setSafetySourceData_twice_replacesValue() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataNone,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataCritical,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isEqualTo(safetySourceDataCritical)
+    }
+
+    @Test
+    fun setSafetySourceData_null_clearsValue() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataNone,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceData = null,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isNull()
+    }
+
+    @Test
+    fun setSafetySourceData_withFlagDisabled_noOp() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataNone,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isNull()
+    }
+
+    @Test
+    fun setSafetySourceData_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.setSafetySourceData(
+                CTS_SOURCE_ID,
+                safetySourceDataNone,
+                EVENT_SOURCE_STATE_CHANGED
+            )
+        }
+    }
+
+    @Test
+    fun getSafetySourceData_validId_noData_returnsNull() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+
+        assertThat(apiSafetySourceData).isNull()
+    }
+
+    @Test
+    fun getSafetySourceData_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.getSafetySourceData(CTS_SOURCE_ID)
+        }
+    }
+
+    @Test
+    fun reportSafetySourceError_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.reportSafetySourceError(
+                CTS_SOURCE_ID,
+                SafetySourceErrorDetails(EVENT_SOURCE_STATE_CHANGED)
+            )
+        }
+    }
+
+    @Test
+    fun refreshSafetySources_withRefreshReasonRescanButtonClick_sourceSendsRescanData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        SafetySourceBroadcastReceiver.safetySourceId = CTS_SOURCE_ID
+        SafetySourceBroadcastReceiver.safetySourceDataOnRescanClick = safetySourceDataCritical
+
+        safetyCenterManager.refreshSafetySourcesWithReceiverPermissionAndWait(
+            REFRESH_REASON_RESCAN_BUTTON_CLICK
+        )
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isEqualTo(safetySourceDataCritical)
+    }
+
+    @Test
+    fun refreshSafetySources_withRefreshReasonPageOpen_sourceSendsPageOpenData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        SafetySourceBroadcastReceiver.safetySourceId = CTS_SOURCE_ID
+        SafetySourceBroadcastReceiver.safetySourceDataOnPageOpen = safetySourceDataOk
+
+        safetyCenterManager.refreshSafetySourcesWithReceiverPermissionAndWait(
+            REFRESH_REASON_PAGE_OPEN)
+
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isEqualTo(safetySourceDataOk)
+    }
+
+    @Test
+    fun refreshSafetySources_whenReceiverDoesNotHaveSendingPermission_sourceDoesNotSendData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        SafetySourceBroadcastReceiver.safetySourceId = CTS_SOURCE_ID
+        SafetySourceBroadcastReceiver.safetySourceDataOnRescanClick = safetySourceDataCritical
+
+        safetyCenterManager.refreshSafetySourcesWithPermission(REFRESH_REASON_RESCAN_BUTTON_CLICK)
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            SafetySourceBroadcastReceiver.waitTillOnReceiveComplete(TIMEOUT_SHORT)
+        }
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isNull()
+    }
+
+    @Test
+    fun refreshSafetySources_withFlagDisabled_noOp() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        SafetySourceBroadcastReceiver.safetySourceId = CTS_SOURCE_ID
+        SafetySourceBroadcastReceiver.safetySourceDataOnPageOpen = safetySourceDataOk
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            safetyCenterManager.refreshSafetySourcesWithReceiverPermissionAndWait(
+                REFRESH_REASON_PAGE_OPEN, TIMEOUT_SHORT)
+        }
+        val apiSafetySourceData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        assertThat(apiSafetySourceData).isNull()
+    }
+
+    @Test
+    fun refreshSafetySources_withInvalidRefreshSeason_throwsIllegalArgumentException() {
+        val thrown =
+            assertFailsWith(IllegalArgumentException::class) {
+                safetyCenterManager.refreshSafetySourcesWithPermission(500)
+            }
+        assertThat(thrown).hasMessageThat().isEqualTo("Invalid refresh reason: 500")
+    }
+
+    @Test
+    fun refreshSafetySources_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.refreshSafetySources(REFRESH_REASON_RESCAN_BUTTON_CLICK)
+        }
+    }
+
+    @Test
+    fun getSafetyCenterData_withoutDataProvided_returnsDataFromConfig() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+
+        val apiSafetyCenterData = safetyCenterManager.getSafetyCenterDataWithPermission()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(apiSafetyCenterData).isNotNull()
+    }
+
+    @Test
+    fun getSafetyCenterData_withSomeDataProvided_returnsDataProvided() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataNone,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetyCenterData = safetyCenterManager.getSafetyCenterDataWithPermission()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(apiSafetyCenterData).isNotNull()
+    }
+
+    @Test
+    fun getSafetyCenterData_withUpdatedData_returnsUpdatedData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        val previousApiSafetyCenterData =
+            safetyCenterManager.getSafetySourceDataWithPermission(CTS_SOURCE_ID)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataCritical,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        val apiSafetyCenterData = safetyCenterManager.getSafetyCenterDataWithPermission()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(apiSafetyCenterData).isNotEqualTo(previousApiSafetyCenterData)
+    }
+
+    @Test
+    fun getSafetyCenterData_withFlagDisabled_returnsDefaultData() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+
+        val apiSafetyCenterData = safetyCenterManager.getSafetyCenterDataWithPermission()
+
+        assertThat(apiSafetyCenterData).isEqualTo(SafetyCenterData(
+            SafetyCenterStatus.Builder()
+                .setSeverityLevel(OVERALL_SEVERITY_LEVEL_UNKNOWN)
+                .setTitle("Unknown")
+                .setSummary("Unknown safety status")
+                .build(),
+            emptyList(),
+            emptyList(),
+            emptyList()
+        ))
+    }
+
+    @Test
+    fun getSafetyCenterData_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) { safetyCenterManager.safetyCenterData }
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerCalledWithSafetyCenterDataFromConfig() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        val safetyCenterDataFromListener = receiveListenerUpdate()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(safetyCenterDataFromListener).isNotNull()
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerCalledOnSafetySourceData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        val safetyCenterDataFromListener = receiveListenerUpdate()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(safetyCenterDataFromListener).isNotNull()
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerCalledWhenSafetySourceDataChanges() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        // Receive update from #setSafetySourceData call.
+        receiveListenerUpdate()
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataCritical,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        val safetyCenterDataFromListener = receiveListenerUpdate()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(safetyCenterDataFromListener).isNotNull()
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerCalledWhenSafetySourceDataCleared() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        // Receive update from #setSafetySourceData call.
+        receiveListenerUpdate()
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceData = null,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        val safetyCenterDataFromListener = receiveListenerUpdate()
+
+        // TODO(b/218830137): Assert on content.
+        assertThat(safetyCenterDataFromListener).isNotNull()
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerNotCalledWhenSafetySourceDataStaysNull() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceData = null,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            receiveListenerUpdate(TIMEOUT_SHORT)
+        }
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_listenerNotCalledWhenSafetySourceDataDoesntChange() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+        // Receive update from #setSafetySourceData call.
+        receiveListenerUpdate()
+
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            receiveListenerUpdate(TIMEOUT_SHORT)
+        }
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_withFlagDisabled_listenerNotCalled() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            receiveListenerUpdate(TIMEOUT_SHORT)
+        }
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.addOnSafetyCenterDataChangedListener(directExecutor(), listener)
+        }
+    }
+
+    @Test
+    fun addOnSafetyCenterDataChangedListener_oneShot_doesntDeadlock() {
+        val >
+            object : OnSafetyCenterDataChangedListener {
+                override fun onSafetyCenterDataChanged(safetyCenterData: SafetyCenterData) {
+                    safetyCenterManager.removeOnSafetyCenterDataChangedListenerWithPermission(this)
+                    runBlockingWithTimeout { listenerChannel.send(safetyCenterData) }
+                }
+            }
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            oneShotListener
+        )
+
+        // Check that we don't deadlock when using a one-shot listener: this is because adding the
+        // listener could call the listener while holding a lock on the binder thread-pool; causing
+        // a deadlock when attempting to call the `SafetyCenterManager` from that listener.
+        receiveListenerUpdate()
+    }
+
+    @Test
+    fun removeOnSafetyCenterDataChangedListener_listenerNotCalledOnSafetySourceData() {
+        safetyCenterManager.setSafetyCenterConfigOverrideWithPermission(CTS_CONFIG)
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+        // Receive initial data.
+        receiveListenerUpdate()
+
+        safetyCenterManager.removeOnSafetyCenterDataChangedListenerWithPermission(listener)
+        safetyCenterManager.setSafetySourceDataWithPermission(
+            CTS_SOURCE_ID,
+            safetySourceDataOk,
+            EVENT_SOURCE_STATE_CHANGED
+        )
+
+        assertFailsWith(TimeoutCancellationException::class) {
+            receiveListenerUpdate(TIMEOUT_SHORT)
+        }
+    }
+
+    @Test
+    fun removeOnSafetyCenterDataChangedListener_withoutPermission_throwsSecurityException() {
+        safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+            directExecutor(),
+            listener
+        )
+
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.removeOnSafetyCenterDataChangedListener(listener)
+        }
+    }
+
+    @Test
+    fun dismissSafetyCenterIssue_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.dismissSafetyCenterIssue("bleh")
+        }
+    }
+
+    @Test
+    fun executeSafetyCenterIssueAction_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.executeSafetyCenterIssueAction("bleh", "blah")
+        }
+    }
+
+    @Test
+    fun clearAllSafetySourceData_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) { safetyCenterManager.clearAllSafetySourceData() }
+    }
+
+    @Test
+    fun setSafetyCenterConfigOverride_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.setSafetyCenterConfigOverride(CTS_CONFIG)
+        }
+    }
+
+    @Test
+    fun clearSafetyCenterConfigOverride_withoutPermission_throwsSecurityException() {
+        assertFailsWith(SecurityException::class) {
+            safetyCenterManager.clearSafetyCenterConfigOverride()
+        }
+    }
+
+    private fun SafetyCenterManager.refreshSafetySourcesWithReceiverPermissionAndWait(
+        refreshReason: Int,
+        timeout: Duration = TIMEOUT_LONG
+    ) {
+        callWithShellPermissionIdentity(
+            {
+                refreshSafetySources(refreshReason)
+                SafetySourceBroadcastReceiver.waitTillOnReceiveComplete(timeout)
+            },
+            SEND_SAFETY_CENTER_UPDATE,
+            MANAGE_SAFETY_CENTER
+        )
+    }
+
+    private fun receiveListenerUpdate(timeout: Duration = TIMEOUT_LONG): SafetyCenterData =
+        runBlockingWithTimeout(timeout) { listenerChannel.receive() }
+
+    private fun <T> runBlockingWithTimeout(
+        timeout: Duration = TIMEOUT_LONG,
+        block: suspend () -> T
+    ) =
+        runBlocking {
+            withTimeout(timeout.toMillis()) { block() }
+        }
+
+    companion object {
+        private const val CTS_PACKAGE_NAME = "android.safetycenter.cts"
+        private const val CTS_BROADCAST_RECEIVER_NAME =
+            "android.safetycenter.testing.SafetySourceBroadcastReceiver"
+        private val TIMEOUT_LONG: Duration = Duration.ofMillis(5000)
+        private val TIMEOUT_SHORT: Duration = Duration.ofMillis(1000)
+        private val EVENT_SOURCE_STATE_CHANGED =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED).build()
+        private const val CTS_SOURCE_ID = "cts_source_id"
+        private const val CTS_SOURCE_GROUP_ID = "cts_source_group"
+
+        // TODO(b/217944317): Consider moving the following to a file where they can be used by
+        //  other tests.
+        private val CTS_SOURCE =
+            SafetySource.Builder(SAFETY_SOURCE_TYPE_DYNAMIC)
+                .setId(CTS_SOURCE_ID)
+                .setPackageName(CTS_PACKAGE_NAME)
+                .setTitleResId(android.R.string.ok)
+                .setSummaryResId(android.R.string.ok)
+                .setIntentAction(ACTION_SAFETY_CENTER)
+                .setBroadcastReceiverClassName(CTS_BROADCAST_RECEIVER_NAME)
+                .setProfile(SafetySource.PROFILE_PRIMARY)
+                .build()
+        private val CTS_SOURCE_GROUP =
+            SafetySourcesGroup.Builder()
+                .setId(CTS_SOURCE_GROUP_ID)
+                .setTitleResId(android.R.string.ok)
+                .setSummaryResId(android.R.string.ok)
+                .addSafetySource(CTS_SOURCE)
+                .build()
+        private val CTS_CONFIG =
+            SafetyCenterConfig.Builder().addSafetySourcesGroup(CTS_SOURCE_GROUP).build()
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryGroupTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryGroupTest.kt
new file mode 100644
index 0000000..9557833
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryGroupTest.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterStaticEntry
+import android.safetycenter.SafetyCenterStaticEntryGroup
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterStaticEntryGroupTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent1 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+    private val pendingIntent2 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Different Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    private val staticEntry1 =
+            SafetyCenterStaticEntry("an entry title", "an entry summary", pendingIntent1)
+    private val staticEntry2 =
+            SafetyCenterStaticEntry("another entry title", "another entry summary", pendingIntent2)
+
+    private val staticEntryGroup =
+            SafetyCenterStaticEntryGroup("a title", listOf(staticEntry1, staticEntry2))
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(SafetyCenterStaticEntryGroup("a title", listOf()).title).isEqualTo("a title")
+        assertThat(SafetyCenterStaticEntryGroup("another title", listOf()).title)
+                .isEqualTo("another title")
+    }
+
+    @Test
+    fun getStaticEntries_returnsStaticEntries() {
+        assertThat(SafetyCenterStaticEntryGroup("", listOf(staticEntry1)).staticEntries)
+                .containsExactly(staticEntry1)
+        assertThat(
+                SafetyCenterStaticEntryGroup("", listOf(staticEntry1, staticEntry2)).staticEntries)
+                .containsExactly(staticEntry1, staticEntry2)
+        assertThat(SafetyCenterStaticEntryGroup("", listOf()).staticEntries).isEmpty()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(staticEntryGroup.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel: Parcel = Parcel.obtain()
+
+        staticEntryGroup.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterStaticEntryGroup.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(staticEntryGroup)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(staticEntryGroup).isEqualTo(staticEntryGroup)
+        assertThat(staticEntryGroup.hashCode()).isEqualTo(staticEntryGroup.hashCode())
+        assertThat(staticEntryGroup.toString()).isEqualTo(staticEntryGroup.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val group = SafetyCenterStaticEntryGroup("a title", listOf(staticEntry1))
+        val equivalentGroup = SafetyCenterStaticEntryGroup("a title", listOf(staticEntry1))
+
+        assertThat(group).isEqualTo(equivalentGroup)
+        assertThat(group.hashCode()).isEqualTo(equivalentGroup.hashCode())
+        assertThat(group.toString()).isEqualTo(equivalentGroup.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentTitles_areNotEqual() {
+        val group = SafetyCenterStaticEntryGroup("a title", listOf(staticEntry1))
+        val differentGroup = SafetyCenterStaticEntryGroup("a different title", listOf(staticEntry1))
+
+        assertThat(group).isNotEqualTo(differentGroup)
+        assertThat(group.toString()).isNotEqualTo(differentGroup.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentStaticEntries_areNotEqual() {
+        val group = SafetyCenterStaticEntryGroup("a title", listOf(staticEntry1))
+        val differentGroup = SafetyCenterStaticEntryGroup("a different title", listOf(staticEntry2))
+
+        assertThat(group).isNotEqualTo(differentGroup)
+        assertThat(group.toString()).isNotEqualTo(differentGroup.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryTest.kt
new file mode 100644
index 0000000..8902aeb
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStaticEntryTest.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterStaticEntry
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterStaticEntryTest {
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    private val pendingIntent1 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+    private val pendingIntent2 = PendingIntent.getActivity(
+            context,
+            /* requestCode= */ 0,
+            Intent("Fake Different Data"),
+            PendingIntent.FLAG_IMMUTABLE)
+
+    private val title1 = "a title"
+    private val title2 = "another title"
+
+    private val summary1 = "a summary"
+    private val summary2 = "another summary"
+
+    private val staticEntry1 = SafetyCenterStaticEntry(title1, summary1, pendingIntent1)
+    private val staticEntry2 = SafetyCenterStaticEntry(title2, summary2, pendingIntent2)
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(staticEntry1.title).isEqualTo(title1)
+        assertThat(staticEntry2.title).isEqualTo(title2)
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        assertThat(staticEntry1.summary).isEqualTo(summary1)
+        assertThat(staticEntry2.summary).isEqualTo(summary2)
+        assertThat(SafetyCenterStaticEntry("", null, pendingIntent1).summary).isNull()
+    }
+
+    @Test
+    fun getPendingIntent_returnsPendingIntent() {
+        assertThat(staticEntry1.pendingIntent).isEqualTo(pendingIntent1)
+        assertThat(staticEntry2.pendingIntent).isEqualTo(pendingIntent2)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(staticEntry1.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel: Parcel = Parcel.obtain()
+
+        staticEntry1.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterStaticEntry.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(staticEntry1)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(staticEntry1).isEqualTo(staticEntry1)
+        assertThat(staticEntry1.hashCode()).isEqualTo(staticEntry1.hashCode())
+        assertThat(staticEntry1.toString()).isEqualTo(staticEntry1.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val staticEntry = SafetyCenterStaticEntry("titlee", "sumaree", pendingIntent1)
+        val equivalentStaticEntry = SafetyCenterStaticEntry("titlee", "sumaree", pendingIntent1)
+
+        assertThat(staticEntry).isEqualTo(equivalentStaticEntry)
+        assertThat(staticEntry.hashCode()).isEqualTo(equivalentStaticEntry.hashCode())
+        assertThat(staticEntry.toString()).isEqualTo(equivalentStaticEntry.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentTitles_areNotEqual() {
+        val staticEntry = SafetyCenterStaticEntry("a title", "a summary", pendingIntent1)
+        val differentStaticEntry =
+                SafetyCenterStaticEntry("a different title", "a summary", pendingIntent1)
+
+        assertThat(staticEntry).isNotEqualTo(differentStaticEntry)
+        assertThat(staticEntry.toString()).isNotEqualTo(differentStaticEntry.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSummaries_areNotEqual() {
+        val staticEntry = SafetyCenterStaticEntry("a title", "a summary", pendingIntent1)
+        val differentStaticEntry =
+                SafetyCenterStaticEntry("a title", "a different summary", pendingIntent1)
+
+        assertThat(staticEntry).isNotEqualTo(differentStaticEntry)
+        assertThat(staticEntry.toString()).isNotEqualTo(differentStaticEntry.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentPendingIntents_areNotEqual() {
+        val staticEntry = SafetyCenterStaticEntry("a title", "a summary", pendingIntent1)
+        val differentStaticEntry = SafetyCenterStaticEntry("a title", "a summary", pendingIntent2)
+
+        assertThat(staticEntry).isNotEqualTo(differentStaticEntry)
+        assertThat(staticEntry.toString()).isNotEqualTo(differentStaticEntry.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStatusTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStatusTest.kt
new file mode 100644
index 0000000..68edbf3
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterStatusTest.kt
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetyCenterStatus
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterStatusTest {
+
+    val baseStatus = SafetyCenterStatus.Builder()
+            .setTitle("This is my title")
+            .setSummary("This is my summary")
+            .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_RECOMMENDATION)
+            .setRefreshStatus(SafetyCenterStatus.REFRESH_STATUS_DATA_FETCH_IN_PROGRESS)
+            .build()
+
+    @Test
+    fun getTitle_returnsTitle() {
+        assertThat(SafetyCenterStatus.Builder(baseStatus).setTitle("title").build().title)
+                .isEqualTo("title")
+
+        assertThat(SafetyCenterStatus.Builder(baseStatus).setTitle("different title").build().title)
+                .isEqualTo("different title")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        assertThat(SafetyCenterStatus.Builder(baseStatus).setSummary("summary").build().summary)
+                .isEqualTo("summary")
+
+        assertThat(
+                SafetyCenterStatus.Builder(baseStatus)
+                        .setSummary("different summary")
+                        .build()
+                        .summary)
+                .isEqualTo("different summary")
+    }
+
+    @Test
+    fun getSeverityLevel_returnsSeverityLevel() {
+        assertThat(
+                SafetyCenterStatus.Builder(baseStatus)
+                        .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+                        .build()
+                        .severityLevel)
+                .isEqualTo(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+
+        assertThat(
+                SafetyCenterStatus.Builder(baseStatus)
+                        .setSeverityLevel(
+                                SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_CRITICAL_WARNING)
+                        .build()
+                        .severityLevel)
+                .isEqualTo(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_CRITICAL_WARNING)
+    }
+
+    @Test
+    fun getSeverityLevel_defaultUnknown() {
+        assertThat(
+                SafetyCenterStatus.Builder()
+                        .setTitle("This is my title")
+                        .setSummary("This is my summary")
+                        .build()
+                        .severityLevel)
+                .isEqualTo(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN)
+    }
+
+    @Test
+    fun getRefreshStatus_returnsRefreshStatus() {
+        assertThat(
+                SafetyCenterStatus.Builder(baseStatus)
+                        .setRefreshStatus(SafetyCenterStatus.REFRESH_STATUS_NONE)
+                        .build()
+                        .refreshStatus)
+                .isEqualTo(SafetyCenterStatus.REFRESH_STATUS_NONE)
+
+        assertThat(
+                SafetyCenterStatus.Builder(baseStatus)
+                        .setRefreshStatus(SafetyCenterStatus.REFRESH_STATUS_FULL_RESCAN_IN_PROGRESS)
+                        .build()
+                        .refreshStatus)
+                .isEqualTo(SafetyCenterStatus.REFRESH_STATUS_FULL_RESCAN_IN_PROGRESS)
+    }
+
+    @Test
+    fun getRefreshStatus_defaultNone() {
+        assertThat(
+                SafetyCenterStatus.Builder()
+                        .setTitle("This is my title")
+                        .setSummary("This is my summary")
+                        .build()
+                        .refreshStatus)
+                .isEqualTo(SafetyCenterStatus.REFRESH_STATUS_NONE)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        assertThat(baseStatus.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val parcel: Parcel = Parcel.obtain()
+        baseStatus.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val fromParcel = SafetyCenterStatus.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(fromParcel).isEqualTo(baseStatus)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        assertThat(baseStatus).isEqualTo(baseStatus)
+        assertThat(baseStatus.hashCode()).isEqualTo(baseStatus.hashCode())
+        assertThat(baseStatus.toString()).isEqualTo(baseStatus.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val status = SafetyCenterStatus.Builder()
+                .setTitle("same title")
+                .setSummary("same summary")
+                .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+                .build()
+        val equivalentStatus = SafetyCenterStatus.Builder()
+                .setTitle("same title")
+                .setSummary("same summary")
+                .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+                .build()
+
+        assertThat(status).isEqualTo(equivalentStatus)
+        assertThat(status.hashCode()).isEqualTo(equivalentStatus.hashCode())
+        assertThat(status.toString()).isEqualTo(equivalentStatus.toString())
+    }
+
+    @Test
+    fun equals_hashCode_toString_fromCopyBuilder_areEqual() {
+        val copyOfBaseStatus = SafetyCenterStatus.Builder(baseStatus).build()
+
+        assertThat(copyOfBaseStatus).isEqualTo(baseStatus)
+        assertThat(copyOfBaseStatus.hashCode()).isEqualTo(baseStatus.hashCode())
+        assertThat(copyOfBaseStatus.toString()).isEqualTo(baseStatus.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentTitles_areNotEqual() {
+        val unequalStatus = SafetyCenterStatus.Builder(baseStatus)
+                .setTitle("that's discarsting")
+                .build()
+
+        assertThat(unequalStatus).isNotEqualTo(baseStatus)
+        assertThat(unequalStatus.toString()).isNotEqualTo(baseStatus.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSummaries_areNotEqual() {
+        val unequalStatus = SafetyCenterStatus.Builder(baseStatus)
+                .setSummary("discarsting sheet")
+                .build()
+
+        assertThat(unequalStatus).isNotEqualTo(baseStatus)
+        assertThat(unequalStatus.toString()).isNotEqualTo(baseStatus.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentSeverityLevels_arNotEqual() {
+        val unequalStatus = SafetyCenterStatus.Builder(baseStatus)
+                .setSeverityLevel(SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_OK)
+                .build()
+
+        assertThat(unequalStatus).isNotEqualTo(baseStatus)
+        assertThat(unequalStatus.toString()).isNotEqualTo(baseStatus.toString())
+    }
+
+    @Test
+    fun equals_toString_withDifferentRefreshStatuses_areNotEqual() {
+        val unequalStatus = SafetyCenterStatus.Builder(baseStatus)
+                .setRefreshStatus(SafetyCenterStatus.REFRESH_STATUS_NONE)
+                .build()
+
+        assertThat(unequalStatus).isNotEqualTo(baseStatus)
+        assertThat(unequalStatus.toString()).isNotEqualTo(baseStatus.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterUnsupportedTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterUnsupportedTest.kt
new file mode 100644
index 0000000..5265a4f
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyCenterUnsupportedTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_SAFETY_CENTER
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.SafetyCenterManager
+import android.safetycenter.testing.SafetyCenterFlags
+import android.safetycenter.testing.SafetyCenterFlags.deviceSupportsSafetyCenter
+import android.safetycenter.testing.isSafetyCenterEnabledWithPermission
+import android.support.test.uiautomator.By
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.android.compatibility.common.util.UiAutomatorUtils.waitFindObject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assume.assumeFalse
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetyCenterUnsupportedTest {
+    private val context: Context = getApplicationContext()
+    private val safetyCenterManager = context.getSystemService(SafetyCenterManager::class.java)!!
+
+    @Before
+    fun assumeDeviceDoesntSupportSafetyCenterToRunTests() {
+        assumeFalse(context.deviceSupportsSafetyCenter())
+    }
+
+    @Test
+    fun launchActivity_showsSecurityTitle() {
+        startSafetyCenterActivity()
+
+        // CollapsingToolbar title can't be found by text, so using description instead.
+        waitFindObject(By.desc("Security"))
+    }
+
+    @Test
+    fun isSafetyCenterEnabled_withFlagEnabled_returnsFalse() {
+        SafetyCenterFlags.setSafetyCenterEnabled(true)
+
+        val isSafetyCenterEnabled = safetyCenterManager.isSafetyCenterEnabledWithPermission()
+
+        assertThat(isSafetyCenterEnabled).isFalse()
+    }
+
+    @Test
+    fun isSafetyCenterEnabled_withFlagDisabled_returnsFalse() {
+        SafetyCenterFlags.setSafetyCenterEnabled(false)
+
+        val isSafetyCenterEnabled = safetyCenterManager.isSafetyCenterEnabledWithPermission()
+
+        assertThat(isSafetyCenterEnabled).isFalse()
+    }
+
+    private fun startSafetyCenterActivity() {
+        context.startActivity(
+            Intent(ACTION_SAFETY_CENTER)
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+        )
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyEventTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyEventTest.kt
new file mode 100644
index 0000000..a13b4e4
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetyEventTest.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.os.Build
+import android.safetycenter.SafetyEvent
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_DEVICE_REBOOTED
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED
+import android.safetycenter.testing.AnyTester.assertThatRepresentationsAreEqual
+import android.safetycenter.testing.AnyTester.assertThatRepresentationsAreNotEqual
+import android.safetycenter.testing.ParcelableTester.assertThatRoundTripReturnsOriginal
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetyEvent]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU, codeName = "Tiramisu")
+class SafetyEventTest {
+    @Test
+    fun getType_returnsType() {
+        val safetyEvent = SafetyEvent.Builder(SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED).build()
+
+        assertThat(safetyEvent.type).isEqualTo(SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED)
+    }
+
+    @Test
+    fun getRefreshBroadcastId_returnsRefreshBroadcastId() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId(REFRESH_BROADCAST_ID)
+                .build()
+
+        assertThat(safetyEvent.refreshBroadcastId).isEqualTo(REFRESH_BROADCAST_ID)
+    }
+
+    @Test
+    fun getSafetySourceIssueId_returnsSafetySourceIssueId() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .build()
+
+        assertThat(safetyEvent.safetySourceIssueId).isEqualTo(SAFETY_SOURCE_ISSUE_ID)
+    }
+
+    @Test
+    fun getSafetySourceIssueActionId_returnsSafetySourceIssueActionId() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+
+        assertThat(safetyEvent.safetySourceIssueActionId).isEqualTo(SAFETY_SOURCE_ISSUE_ACTION_ID)
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+
+        assertThat(safetyEvent.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySourceData() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+
+        assertThatRoundTripReturnsOriginal(safetyEvent, SafetyEvent.CREATOR)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_areEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId(REFRESH_BROADCAST_ID)
+                .build()
+        val otherSafetyEvent = safetyEvent
+
+        assertThatRepresentationsAreEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+        val otherSafetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+
+        assertThatRepresentationsAreEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSafetyEventTypes_areNotEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .build()
+        val otherSafetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_DEVICE_REBOOTED)
+                .build()
+
+        assertThatRepresentationsAreNotEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentRefreshBroadcastIds_areNotEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId(REFRESH_BROADCAST_ID)
+                .build()
+        val otherSafetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId(OTHER_REFRESH_BROADCAST_ID)
+                .build()
+
+        assertThatRepresentationsAreNotEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIssueIds_areNotEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .build()
+        val otherSafetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(OTHER_SAFETY_SOURCE_ISSUE_ID)
+                .build()
+
+        assertThatRepresentationsAreNotEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentActionIds_areNotEqual() {
+        val safetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+        val otherSafetyEvent =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_RESOLVING_ACTION_FAILED)
+                .setSafetySourceIssueId(SAFETY_SOURCE_ISSUE_ID)
+                .setSafetySourceIssueActionId(OTHER_SAFETY_SOURCE_ISSUE_ACTION_ID)
+                .build()
+
+        assertThatRepresentationsAreNotEqual(safetyEvent, otherSafetyEvent)
+    }
+
+    companion object {
+        const val REFRESH_BROADCAST_ID = "refresh_broadcast_id"
+        const val OTHER_REFRESH_BROADCAST_ID = "other_refresh_broadcast_id"
+        const val SAFETY_SOURCE_ISSUE_ID = "safety_source_issue_id"
+        const val OTHER_SAFETY_SOURCE_ISSUE_ID = "other_safety_source_issue_id"
+        const val SAFETY_SOURCE_ISSUE_ACTION_ID = "safety_source_issue_action_id"
+        const val OTHER_SAFETY_SOURCE_ISSUE_ACTION_ID = "other_safety_source_issue_action_id"
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceDataTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceDataTest.kt
new file mode 100644
index 0000000..76090bf
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceDataTest.kt
@@ -0,0 +1,296 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetySourceData
+import android.safetycenter.SafetySourceIssue
+import android.safetycenter.SafetySourceIssue.ISSUE_CATEGORY_ACCOUNT
+import android.safetycenter.SafetySourceStatus
+import android.safetycenter.SafetySourceStatus.IconAction.ICON_TYPE_GEAR
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySourceData]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetySourceDataTest {
+    private val context: Context = getApplicationContext()
+
+    private val status1 = SafetySourceStatus.Builder(
+            "Status title 1",
+            "Status summary 1",
+            SafetySourceStatus.STATUS_LEVEL_NONE,
+            PendingIntent.getActivity(context, 0 /* requestCode= */,
+                    Intent("Status PendingIntent 1"), FLAG_IMMUTABLE))
+            .setEnabled(false)
+            .build()
+    private val status2 = SafetySourceStatus.Builder(
+            "Status title 2",
+            "Status summary 2",
+            SafetySourceStatus.STATUS_LEVEL_RECOMMENDATION,
+            PendingIntent.getActivity(context, 0 /* requestCode= */,
+                    Intent("Status PendingIntent 2"), FLAG_IMMUTABLE))
+            .setIconAction(SafetySourceStatus.IconAction(ICON_TYPE_GEAR,
+                    PendingIntent.getActivity(context, 0 /* requestCode= */,
+                            Intent("IconAction PendingIntent 2"), FLAG_IMMUTABLE)))
+            .build()
+    private val issue1 = SafetySourceIssue.Builder(
+        "Issue id 1",
+        "Issue summary 1",
+        "Issue summary 1",
+        SafetySourceIssue.SEVERITY_LEVEL_INFORMATION, "issue_type_id"
+    )
+        .setSubtitle("Issue subtitle 1")
+        .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+        .addAction(
+            SafetySourceIssue.Action.Builder(
+                "action_id_1",
+                "Action label 1",
+                PendingIntent.getActivity(
+                    context, 0 /* requestCode= */,
+                    Intent("Issue PendingIntent 1"), FLAG_IMMUTABLE
+                )
+            )
+                .build()
+        )
+        .build()
+    private val issue2 = SafetySourceIssue.Builder(
+        "Issue id 2",
+        "Issue title 2",
+        "Issue summary 2",
+        SafetySourceIssue.SEVERITY_LEVEL_RECOMMENDATION, "issue_type_id"
+    )
+        .addAction(
+            SafetySourceIssue.Action.Builder(
+                "action_id_2",
+                "Action label 2",
+                PendingIntent.getService(
+                    context, 0 /* requestCode= */,
+                    Intent("Issue PendingIntent 2"), FLAG_IMMUTABLE
+                )
+            ).build()
+        )
+        .setOnDismissPendingIntent(
+            PendingIntent.getService(
+                context,
+                0 /* requestCode= */,
+                Intent("Issue OnDismissPendingIntent 2"), FLAG_IMMUTABLE
+            )
+        )
+        .build()
+
+    @Test
+    fun getStatus_withDefaultBuilder_returnsNull() {
+        val safetySourceData = SafetySourceData.Builder().build()
+
+        assertThat(safetySourceData.status).isNull()
+    }
+
+    @Test
+    fun getStatus_whenSetExplicitly_returnsStatus() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .build()
+
+        assertThat(safetySourceData.status).isEqualTo(status1)
+    }
+
+    @Test
+    fun getIssues_withDefaultBuilder_returnsEmptyList() {
+        val safetySourceData = SafetySourceData.Builder().build()
+
+        assertThat(safetySourceData.issues).isEmpty()
+    }
+
+    @Test
+    fun getIssues_whenSetExplicitly_returnsIssues() {
+        val safetySourceData = SafetySourceData.Builder()
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+
+        assertThat(safetySourceData.issues).containsExactly(issue1, issue2).inOrder()
+    }
+
+    @Test
+    fun clearIssues_removesAllIssues() {
+        val safetySourceData = SafetySourceData.Builder()
+            .addIssue(issue1)
+            .addIssue(issue2)
+            .clearIssues()
+            .build()
+
+        assertThat(safetySourceData.issues).isEmpty()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+
+        assertThat(safetySourceData.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySourceData() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+
+        val parcel: Parcel = Parcel.obtain()
+        safetySourceData.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val safetySourceDataFromParcel: SafetySourceData =
+                SafetySourceData.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(safetySourceDataFromParcel).isEqualTo(safetySourceData)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_withoutStatusAndIssues_areEqual() {
+        val safetySourceData = SafetySourceData.Builder().build()
+        val otherSafetySourceData = safetySourceData
+
+        assertThat(safetySourceData.hashCode()).isEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_withoutIssues_areEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .build()
+        val otherSafetySourceData = safetySourceData
+
+        assertThat(safetySourceData.hashCode()).isEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withEqualByReference_areEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+        val otherSafetySourceData = safetySourceData
+
+        assertThat(safetySourceData.hashCode()).isEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+        val otherSafetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+
+        assertThat(safetySourceData.hashCode()).isEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIssues_areNotEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+        val otherSafetySourceData = SafetySourceData.Builder()
+                .setStatus(status2)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+
+        assertThat(safetySourceData.hashCode()).isNotEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isNotEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isNotEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentStatuses_areNotEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+        val otherSafetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .build()
+
+        assertThat(safetySourceData.hashCode()).isNotEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isNotEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isNotEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withStatusSetInOneAndNotOther_areNotEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .build()
+        val otherSafetySourceData = SafetySourceData.Builder().build()
+
+        assertThat(safetySourceData.hashCode()).isNotEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isNotEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isNotEqualTo(otherSafetySourceData.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withIssuesSetInOneAndNotOther_areNotEqual() {
+        val safetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .addIssue(issue1)
+                .addIssue(issue2)
+                .build()
+        val otherSafetySourceData = SafetySourceData.Builder()
+                .setStatus(status1)
+                .build()
+
+        assertThat(safetySourceData.hashCode()).isNotEqualTo(otherSafetySourceData.hashCode())
+        assertThat(safetySourceData).isNotEqualTo(otherSafetySourceData)
+        assertThat(safetySourceData.toString()).isNotEqualTo(otherSafetySourceData.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceErrorDetailsTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceErrorDetailsTest.kt
new file mode 100644
index 0000000..ed9136f
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceErrorDetailsTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.os.Build
+import android.safetycenter.SafetyEvent
+import android.safetycenter.SafetySourceErrorDetails
+import android.safetycenter.testing.AnyTester.assertThatRepresentationsAreEqual
+import android.safetycenter.testing.AnyTester.assertThatRepresentationsAreNotEqual
+import android.safetycenter.testing.ParcelableTester.assertThatRoundTripReturnsOriginal
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySourceErrorDetails]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU, codeName = "Tiramisu")
+class SafetySourceErrorDetailsTest {
+    @Test
+    fun getSafetyEvent_returnsSafetyEvent() {
+        val errorDetails = SafetySourceErrorDetails(SAFETY_EVENT)
+
+        assertThat(errorDetails.safetyEvent).isEqualTo(SAFETY_EVENT)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsEquivalentObject() {
+        val errorDetails = SafetySourceErrorDetails(SAFETY_EVENT)
+
+        assertThatRoundTripReturnsOriginal(errorDetails, SafetySourceErrorDetails.CREATOR)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByReference_areEqual() {
+        val errorDetails = SafetySourceErrorDetails(SAFETY_EVENT)
+
+        assertThatRepresentationsAreEqual(errorDetails, errorDetails)
+    }
+
+    @Test
+    fun equals_hashCode_toString_equalByValue_areEqual() {
+        val errorDetails = SafetySourceErrorDetails(SAFETY_EVENT)
+        val equivalentSafetySourceErrorDetails = SafetySourceErrorDetails(SAFETY_EVENT)
+
+        assertThatRepresentationsAreEqual(errorDetails, equivalentSafetySourceErrorDetails)
+    }
+
+    @Test
+    fun equals_toString_withDifferentSafetyEvents_areNotEqual() {
+        val errorDetails = SafetySourceErrorDetails(
+            SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED).build())
+        val otherErrorDetails = SafetySourceErrorDetails(
+            SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_DEVICE_REBOOTED).build())
+
+        assertThatRepresentationsAreNotEqual(errorDetails, otherErrorDetails)
+    }
+
+    companion object {
+        private val SAFETY_EVENT =
+            SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_SOURCE_STATE_CHANGED).build()
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceIssueTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceIssueTest.kt
new file mode 100644
index 0000000..890e690
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceIssueTest.kt
@@ -0,0 +1,790 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetySourceIssue
+import android.safetycenter.SafetySourceIssue.ISSUE_CATEGORY_ACCOUNT
+import android.safetycenter.SafetySourceIssue.ISSUE_CATEGORY_DEVICE
+import android.safetycenter.SafetySourceIssue.ISSUE_CATEGORY_GENERAL
+import android.safetycenter.SafetySourceIssue.Action
+import android.safetycenter.SafetySourceIssue.SEVERITY_LEVEL_CRITICAL_WARNING
+import android.safetycenter.SafetySourceIssue.SEVERITY_LEVEL_INFORMATION
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySourceIssue]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetySourceIssueTest {
+    private val context: Context = getApplicationContext()
+
+    private val pendingIntent1: PendingIntent = PendingIntent.getActivity(
+        context,
+        0 /* requestCode= */, Intent("PendingIntent 1"), FLAG_IMMUTABLE
+    )
+    private val action1 = Action.Builder("action_id_1", "Action label 1", pendingIntent1).build()
+    private val pendingIntent2: PendingIntent = PendingIntent.getActivity(
+        context,
+        0 /* requestCode= */, Intent("PendingIntent 2"), FLAG_IMMUTABLE
+    )
+    private val action2 = Action.Builder("action_id_2", "Action label 2", pendingIntent2).build()
+    private val action3 = Action.Builder("action_id_3", "Action label 3", pendingIntent1).build()
+
+    @Test
+    fun action_getId_returnsId() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.id).isEqualTo("action_id")
+    }
+
+    @Test
+    fun action_getLabel_returnsLabel() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.label).isEqualTo("Action label")
+    }
+
+    @Test
+    fun action_willResolve_withDefaultBuilder_returnsFalse() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.willResolve()).isFalse()
+    }
+
+    @Test
+    fun action_willResolve_whenSetExplicitly_returnsWillResolve() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1)
+            .setWillResolve(true)
+            .build()
+
+        assertThat(action.willResolve()).isTrue()
+    }
+
+    @Test
+    fun action_getPendingIntent_returnsPendingIntent() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.pendingIntent).isEqualTo(pendingIntent1)
+    }
+
+    @Test
+    fun action_getSuccessMessage_withDefaultBuilder_returnsNull() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.successMessage).isNull()
+    }
+
+    @Test
+    fun action_getSuccessMessage_whenSetExplicitly_returnsSuccessMessage() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1)
+            .setSuccessMessage("Action successfully completed")
+            .build()
+
+        assertThat(action.successMessage).isEqualTo("Action successfully completed")
+    }
+
+    @Test
+    fun action_describeContents_returns0() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun action_createFromParcel_withWriteToParcel_returnsOriginalAction() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1)
+            .setSuccessMessage("Action successfully completed")
+            .build()
+
+        val parcel: Parcel = Parcel.obtain()
+        action.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val actionFromParcel: Action = Action.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(actionFromParcel).isEqualTo(action)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun action_hashCode_equals_toString_withEqualByReferenceActions_areEqual() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+        val otherAction = action
+
+        assertThat(action.hashCode()).isEqualTo(otherAction.hashCode())
+        assertThat(action).isEqualTo(otherAction)
+        assertThat(action.toString()).isEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+        val otherAction = Action.Builder("action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.hashCode()).isEqualTo(otherAction.hashCode())
+        assertThat(action).isEqualTo(otherAction)
+        assertThat(action.toString()).isEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withDifferentIds_areNotEqual() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+        val otherAction = Action.Builder("other_action_id", "Action label", pendingIntent1).build()
+
+        assertThat(action.hashCode()).isNotEqualTo(otherAction.hashCode())
+        assertThat(action).isNotEqualTo(otherAction)
+        assertThat(action.toString()).isNotEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withDifferentLabels_areNotEqual() {
+        val action = Action.Builder("action_id", "Action label", pendingIntent1).build()
+        val otherAction = Action.Builder("action_id", "Other action label", pendingIntent1).build()
+
+        assertThat(action.hashCode()).isNotEqualTo(otherAction.hashCode())
+        assertThat(action).isNotEqualTo(otherAction)
+        assertThat(action.toString()).isNotEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withDifferentWillResolve_areNotEqual() {
+        val action =
+            Action.Builder("action_id", "Action label", pendingIntent1).setWillResolve(false)
+                .build()
+        val otherAction =
+            Action.Builder("action_id", "Action label", pendingIntent1).setWillResolve(true).build()
+
+        assertThat(action.hashCode()).isNotEqualTo(otherAction.hashCode())
+        assertThat(action).isNotEqualTo(otherAction)
+        assertThat(action.toString()).isNotEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withDifferentPendingIntents_areNotEqual() {
+        val action = Action.Builder(
+            "action_id",
+            "Action label",
+            PendingIntent.getActivity(
+                context, 0 /* requestCode= */,
+                Intent("Action PendingIntent"), FLAG_IMMUTABLE
+            )
+        )
+            .build()
+        val otherAction = Action.Builder(
+            "action_id",
+            "Action label",
+            PendingIntent.getActivity(
+                context, 0 /* requestCode= */,
+                Intent("Other action PendingIntent"), FLAG_IMMUTABLE
+            )
+        )
+            .build()
+
+        assertThat(action.hashCode()).isNotEqualTo(otherAction.hashCode())
+        assertThat(action).isNotEqualTo(otherAction)
+        assertThat(action.toString()).isNotEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun action_hashCode_equals_toString_withDifferentSuccessMessages_areNotEqual() {
+        val action =
+            Action.Builder("action_id", "Action label", pendingIntent1)
+                .setSuccessMessage("Action successfully completed")
+                .build()
+        val otherAction =
+            Action.Builder("action_id", "Action label", pendingIntent1)
+                .setSuccessMessage("Other action successfully completed")
+                .build()
+
+        assertThat(action.hashCode()).isNotEqualTo(otherAction.hashCode())
+        assertThat(action).isNotEqualTo(otherAction)
+        assertThat(action.toString()).isNotEqualTo(otherAction.toString())
+    }
+
+    @Test
+    fun getId_returnsId() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.id).isEqualTo("Issue id")
+    }
+
+    @Test
+    fun getTitle_returnsTitle() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.title).isEqualTo("Issue title")
+    }
+
+    @Test
+    fun getSubtitle_withDefaultBuilder_returnsNull() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.subtitle).isNull()
+    }
+
+    @Test
+    fun getSubtitle_whenSetExplicitly_returnsSubtitle() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .setSubtitle("Issue subtitle")
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.subtitle).isEqualTo("Issue subtitle")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.summary).isEqualTo("Issue summary")
+    }
+
+    @Test
+    fun getSeverityLevel_returnsSeverityLevel() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.severityLevel).isEqualTo(SEVERITY_LEVEL_INFORMATION)
+    }
+
+    @Test
+    fun getIssueCategory_withDefaultBuilder_returnsGeneralCategory() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.issueCategory).isEqualTo(ISSUE_CATEGORY_GENERAL)
+    }
+
+    @Test
+    fun getIssueCategory_whenSetExplicitly_returnsIssueCategory() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .setIssueCategory(ISSUE_CATEGORY_DEVICE)
+            .build()
+
+        assertThat(safetySourceIssue.issueCategory).isEqualTo(ISSUE_CATEGORY_DEVICE)
+    }
+
+    @Test
+    fun getActions_returnsActions() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .addAction(action2)
+            .build()
+
+        assertThat(safetySourceIssue.actions).containsExactly(action1, action2).inOrder()
+    }
+
+    @Test
+    fun clearActions_removesAllActions() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .addAction(action2)
+            .clearActions()
+            .addAction(action3)
+            .build()
+
+        assertThat(safetySourceIssue.actions).containsExactly(action3)
+    }
+
+    @Test
+    fun getOnDismissPendingIntent_withDefaultBuilder_returnsNull() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.onDismissPendingIntent).isNull()
+    }
+
+    @Test
+    fun getOnDismissPendingIntent_whenSetExplicitly_returnsOnDismissPendingIntent() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+
+        assertThat(safetySourceIssue.onDismissPendingIntent).isEqualTo(pendingIntent1)
+    }
+
+    @Test
+    fun getIssueTypeId_returnsIssueTypeId() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.issueTypeId).isEqualTo("issue_type_id")
+    }
+
+    @Test
+    fun build_withNoActions_throwsIllegalArgumentException() {
+        val safetySourceIssueBuilder = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+
+        val exception = assertFailsWith(IllegalArgumentException::class) {
+            safetySourceIssueBuilder.build()
+        }
+        assertThat(exception)
+            .hasMessageThat()
+            .isEqualTo("Safety source issue must contain at least 1 action")
+    }
+
+    @Test
+    fun build_withMoreThanTwoActions_throwsIllegalArgumentException() {
+        val safetySourceIssueBuilder = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .addAction(action2)
+            .addAction(action1)
+
+        val exception = assertFailsWith(IllegalArgumentException::class) {
+            safetySourceIssueBuilder.build()
+        }
+        assertThat(exception)
+            .hasMessageThat()
+            .isEqualTo("Safety source issue must not contain more than 2 actions")
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+            .addAction(action1)
+            .addAction(action2)
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+
+        assertThat(safetySourceIssue.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySourceIssue() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+            .addAction(action1)
+            .addAction(action2)
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+
+        val parcel: Parcel = Parcel.obtain()
+        safetySourceIssue.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val safetySourceIssueFromParcel: SafetySourceIssue =
+            SafetySourceIssue.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(safetySourceIssueFromParcel).isEqualTo(safetySourceIssue)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReferenceSafetySourceIssues_areEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+            .addAction(action1)
+            .addAction(action2)
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+        val otherSafetySourceIssue = safetySourceIssue
+
+        assertThat(safetySourceIssue.hashCode()).isEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+            .addAction(
+                Action.Builder("action_id", "Action label 1", pendingIntent1)
+                    .setWillResolve(false)
+                    .build()
+            )
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .setIssueCategory(ISSUE_CATEGORY_ACCOUNT)
+            .addAction(
+                Action.Builder("action_id", "Action label 1", pendingIntent1)
+                    .setWillResolve(false)
+                    .build()
+            )
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIds_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Other issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTitles_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Other issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSubtitles_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Issue subtitle")
+            .addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).setSubtitle("Other issue subtitle")
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSummaries_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Other issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSeverityLevels_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_CRITICAL_WARNING,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIssueCategories_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .setIssueCategory(ISSUE_CATEGORY_DEVICE)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        )
+            .addAction(action1)
+            .setIssueCategory(ISSUE_CATEGORY_GENERAL)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentActions_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .addAction(action2)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action2)
+            .addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentOnDismissPendingIntents_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .setOnDismissPendingIntent(pendingIntent1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .setOnDismissPendingIntent(pendingIntent2)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIssueTypeIds_areNotEqual() {
+        val safetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "issue_type_id"
+        ).addAction(action1)
+            .build()
+        val otherSafetySourceIssue = SafetySourceIssue.Builder(
+            "Issue id",
+            "Issue title",
+            "Issue summary",
+            SEVERITY_LEVEL_INFORMATION,
+            "other_issue_type_id"
+        ).addAction(action1)
+            .build()
+
+        assertThat(safetySourceIssue.hashCode()).isNotEqualTo(otherSafetySourceIssue.hashCode())
+        assertThat(safetySourceIssue).isNotEqualTo(otherSafetySourceIssue)
+        assertThat(safetySourceIssue.toString()).isNotEqualTo(otherSafetySourceIssue.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceStatusTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceStatusTest.kt
new file mode 100644
index 0000000..b44f6c5
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/SafetySourceStatusTest.kt
@@ -0,0 +1,458 @@
+/*
+ * Copyright (C) 2021 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 android.safetycenter.cts
+
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.os.Parcel
+import android.safetycenter.SafetySourceStatus
+import android.safetycenter.SafetySourceStatus.IconAction
+import android.safetycenter.SafetySourceStatus.IconAction.ICON_TYPE_GEAR
+import android.safetycenter.SafetySourceStatus.IconAction.ICON_TYPE_INFO
+import android.safetycenter.SafetySourceStatus.STATUS_LEVEL_CRITICAL_WARNING
+import android.safetycenter.SafetySourceStatus.STATUS_LEVEL_OK
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** CTS tests for [SafetySourceStatus]. */
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class SafetySourceStatusTest {
+    private val context: Context = getApplicationContext()
+
+    private val pendingIntent1: PendingIntent = PendingIntent.getActivity(
+        context,
+        0 /* requestCode= */, Intent("PendingIntent 1"), FLAG_IMMUTABLE
+    )
+    private val iconAction1 = IconAction(ICON_TYPE_INFO, pendingIntent1)
+    private val pendingIntent2: PendingIntent = PendingIntent.getActivity(
+        context,
+        0 /* requestCode= */, Intent("PendingIntent 2"), FLAG_IMMUTABLE
+    )
+    private val iconAction2 = IconAction(ICON_TYPE_GEAR, pendingIntent2)
+
+    @Test
+    fun iconAction_getIconType_returnsIconType() {
+        val iconAction = IconAction(ICON_TYPE_INFO, pendingIntent1)
+
+        assertThat(iconAction.iconType).isEqualTo(ICON_TYPE_INFO)
+    }
+
+    @Test
+    fun iconAction_getPendingIntent_returnsPendingIntent() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+
+        assertThat(iconAction.pendingIntent).isEqualTo(pendingIntent1)
+    }
+
+    @Test
+    fun iconAction_describeContents_returns0() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+
+        assertThat(iconAction.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun iconAction_createFromParcel_withWriteToParcel_returnsOriginalAction() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+
+        val parcel: Parcel = Parcel.obtain()
+        iconAction.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val iconActionFromParcel: IconAction = IconAction.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(iconActionFromParcel).isEqualTo(iconAction)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun iconAction_hashCode_equals_toString_withEqualByReferenceIconActions_areEqual() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+        val otherIconAction = iconAction
+
+        assertThat(iconAction.hashCode()).isEqualTo(otherIconAction.hashCode())
+        assertThat(iconAction).isEqualTo(otherIconAction)
+        assertThat(iconAction.toString()).isEqualTo(otherIconAction.toString())
+    }
+
+    @Test
+    fun iconAction_hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+        val otherIconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+
+        assertThat(iconAction.hashCode()).isEqualTo(otherIconAction.hashCode())
+        assertThat(iconAction).isEqualTo(otherIconAction)
+        assertThat(iconAction.toString()).isEqualTo(otherIconAction.toString())
+    }
+
+    @Test
+    fun iconAction_hashCode_equals_toString_withDifferentIconTypes_areNotEqual() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+        val otherIconAction = IconAction(ICON_TYPE_INFO, pendingIntent1)
+
+        assertThat(iconAction.hashCode()).isNotEqualTo(otherIconAction.hashCode())
+        assertThat(iconAction).isNotEqualTo(otherIconAction)
+        assertThat(iconAction.toString()).isNotEqualTo(otherIconAction.toString())
+    }
+
+    @Test
+    fun iconAction_hashCode_equals_toString_withDifferentPendingIntents_areNotEqual() {
+        val iconAction = IconAction(ICON_TYPE_GEAR, pendingIntent1)
+        val otherIconAction = IconAction(ICON_TYPE_GEAR, pendingIntent2)
+
+        assertThat(iconAction.hashCode()).isNotEqualTo(otherIconAction.hashCode())
+        assertThat(iconAction).isNotEqualTo(otherIconAction)
+        assertThat(iconAction.toString()).isNotEqualTo(otherIconAction.toString())
+    }
+
+    @Test
+    fun getTitle_returnsTitle() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.title).isEqualTo("Status title")
+    }
+
+    @Test
+    fun getSummary_returnsSummary() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.summary).isEqualTo("Status summary")
+    }
+
+    @Test
+    fun getStatusLevel_returnsStatusLevel() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.statusLevel).isEqualTo(STATUS_LEVEL_OK)
+    }
+
+    @Test
+    fun getPendingIntent_returnsPendingIntent() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.pendingIntent).isEqualTo(pendingIntent1)
+    }
+
+    @Test
+    fun getIconAction_withDefaultBuilder_returnsNull() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.iconAction).isNull()
+    }
+
+    @Test
+    fun getIconAction_whenSetExplicitly_returnsIconAction() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .build()
+
+        assertThat(safetySourceStatus.iconAction).isEqualTo(iconAction1)
+    }
+
+    @Test
+    fun isEnabled_withDefaultBuilder_returnsTrue() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.isEnabled).isTrue()
+    }
+
+    @Test
+    fun isEnabled_whenSetExplicitly_returnsEnabled() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setEnabled(false)
+            .build()
+
+        assertThat(safetySourceStatus.isEnabled).isFalse()
+    }
+
+    @Test
+    fun describeContents_returns0() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .build()
+
+        assertThat(safetySourceStatus.describeContents()).isEqualTo(0)
+    }
+
+    @Test
+    fun createFromParcel_withWriteToParcel_returnsOriginalSafetySourceStatus() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .setEnabled(true)
+            .build()
+
+        val parcel: Parcel = Parcel.obtain()
+        safetySourceStatus.writeToParcel(parcel, 0 /* flags */)
+        parcel.setDataPosition(0)
+        val safetySourceStatusFromParcel: SafetySourceStatus =
+            SafetySourceStatus.CREATOR.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(safetySourceStatusFromParcel).isEqualTo(safetySourceStatus)
+    }
+
+    // TODO(b/208473675): Use `EqualsTester` for testing `hashcode` and `equals`.
+    @Test
+    fun hashCode_equals_toString_withEqualByReferenceSafetySourceStatuses_areEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .setEnabled(true)
+            .build()
+        val otherSafetySourceStatus = safetySourceStatus
+
+        assertThat(safetySourceStatus.hashCode()).isEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withAllFieldsEqual_areEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .setEnabled(true)
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .setEnabled(true)
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentTitles_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Other status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentSummaries_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Other status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentStatusLevels_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            pendingIntent1
+        )
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            pendingIntent1
+        )
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentPendingIntents_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_OK,
+            PendingIntent.getActivity(
+                context, 0 /* requestCode= */,
+                Intent("Status PendingIntent"), FLAG_IMMUTABLE
+            )
+        )
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            PendingIntent.getActivity(
+                context, 0 /* requestCode= */,
+                Intent("Other status PendingIntent"), FLAG_IMMUTABLE
+            )
+        )
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentIconActions_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            pendingIntent1
+        )
+            .setIconAction(iconAction1)
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            pendingIntent1
+        )
+            .setIconAction(iconAction2)
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+
+    @Test
+    fun hashCode_equals_toString_withDifferentEnabled_areNotEqual() {
+        val safetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            pendingIntent1
+        )
+            .setEnabled(true)
+            .build()
+        val otherSafetySourceStatus = SafetySourceStatus.Builder(
+            "Status title",
+            "Status summary",
+            STATUS_LEVEL_CRITICAL_WARNING,
+            pendingIntent1
+        )
+            .setEnabled(false)
+            .build()
+
+        assertThat(safetySourceStatus.hashCode()).isNotEqualTo(otherSafetySourceStatus.hashCode())
+        assertThat(safetySourceStatus).isNotEqualTo(otherSafetySourceStatus)
+        assertThat(safetySourceStatus.toString()).isNotEqualTo(otherSafetySourceStatus.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/cts/XmlConfigTest.kt b/tests/cts/safetycenter/src/android/safetycenter/cts/XmlConfigTest.kt
new file mode 100644
index 0000000..9b77e99
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/cts/XmlConfigTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.cts
+
+import android.os.Build.VERSION_CODES.TIRAMISU
+import android.safetycenter.config.SafetyCenterConfig
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.android.safetycenter.resources.SafetyCenterResourcesContext
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = TIRAMISU, codeName = "Tiramisu")
+class XmlConfigTest {
+    private val safetyCenterContext = SafetyCenterResourcesContext(getApplicationContext())
+
+    @Test
+    fun safetyCenterConfigResource_validConfig() {
+        // Assert that the parser validates the Safety Center config without throwing any exception
+        assertThat(SafetyCenterConfig.fromXml(safetyCenterContext.safetyCenterConfig!!)).isNotNull()
+    }
+}
diff --git a/tests/cts/safetycenter/src/android/safetycenter/testing/AnyTester.kt b/tests/cts/safetycenter/src/android/safetycenter/testing/AnyTester.kt
new file mode 100644
index 0000000..50c37a3
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/testing/AnyTester.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.testing
+
+import com.google.common.truth.Truth.assertThat
+
+/** Collection of functions to test generic objects */
+object AnyTester {
+    /**
+     * Asserts that two generic objects are equal and that the values returned by the [hashCode] and
+     * [toString] methods for the two generic objects are also equal.
+     */
+    fun assertThatRepresentationsAreEqual(a: Any, b: Any) {
+        assertThat(a.hashCode()).isEqualTo(b.hashCode())
+        assertThat(a).isEqualTo(b)
+        assertThat(a.toString()).isEqualTo(b.toString())
+    }
+
+    /**
+     * Asserts that two generic objects are not equal and that the values returned by the [hashCode]
+     * and [toString] methods for the two generic objects are also not equal.
+     */
+    fun assertThatRepresentationsAreNotEqual(a: Any, b: Any) {
+        assertThat(a.hashCode()).isNotEqualTo(b.hashCode())
+        assertThat(a).isNotEqualTo(b)
+        assertThat(a.toString()).isNotEqualTo(b.toString())
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/testing/ParcelableTester.kt b/tests/cts/safetycenter/src/android/safetycenter/testing/ParcelableTester.kt
new file mode 100644
index 0000000..0ba5999
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/testing/ParcelableTester.kt
@@ -0,0 +1,25 @@
+package android.safetycenter.testing
+
+import android.os.Parcel
+import android.os.Parcelable
+import com.google.common.truth.Truth.assertThat
+
+/** Collection of functions to test [Parcelable] objects */
+object ParcelableTester {
+    /**
+     * Asserts that writing a [Parcelable] object to a [Parcel] and creating an object from that
+     * [Parcel] returns an object that is equal to the original [Parcelable] object.
+     */
+    fun <T : Parcelable> assertThatRoundTripReturnsOriginal(
+        parcelable: T,
+        creator: Parcelable.Creator<T>
+    ) {
+        val parcel: Parcel = Parcel.obtain()
+        parcelable.writeToParcel(parcel, 0)
+        parcel.setDataPosition(0)
+        val parcelableFromParcel: T = creator.createFromParcel(parcel)
+        parcel.recycle()
+
+        assertThat(parcelableFromParcel).isEqualTo(parcelable)
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterApisWithShellPermissions.kt b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterApisWithShellPermissions.kt
new file mode 100644
index 0000000..1533904
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterApisWithShellPermissions.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.testing
+
+import android.Manifest.permission.MANAGE_SAFETY_CENTER
+import android.Manifest.permission.READ_SAFETY_CENTER_STATUS
+import android.Manifest.permission.SEND_SAFETY_CENTER_UPDATE
+import android.safetycenter.SafetyCenterManager
+import android.safetycenter.SafetyCenterManager.OnSafetyCenterDataChangedListener
+import android.safetycenter.SafetyEvent
+import android.safetycenter.SafetySourceData
+import android.safetycenter.SafetySourceErrorDetails
+import android.safetycenter.config.SafetyCenterConfig
+import com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity
+import java.util.concurrent.Executor
+
+/**
+ * Calls [SafetyCenterManager.isSafetyCenterEnabled] adopting Shell's [READ_SAFETY_CENTER_STATUS]
+ * permission.
+ */
+fun SafetyCenterManager.isSafetyCenterEnabledWithPermission() =
+    callWithShellPermissionIdentity({ isSafetyCenterEnabled }, READ_SAFETY_CENTER_STATUS)
+
+/**
+ * Calls [SafetyCenterManager.setSafetySourceData] adopting Shell's [SEND_SAFETY_CENTER_UPDATE]
+ * permission.
+ */
+fun SafetyCenterManager.setSafetySourceDataWithPermission(
+    safetySourceId: String,
+    safetySourceData: SafetySourceData?,
+    safetyEvent: SafetyEvent
+) =
+    callWithShellPermissionIdentity(
+        { setSafetySourceData(safetySourceId, safetySourceData, safetyEvent) },
+        SEND_SAFETY_CENTER_UPDATE
+    )
+
+/**
+ * Calls [SafetyCenterManager.getSafetySourceData] adopting Shell's [SEND_SAFETY_CENTER_UPDATE]
+ * permission.
+ */
+fun SafetyCenterManager.getSafetySourceDataWithPermission(id: String) =
+    callWithShellPermissionIdentity({ getSafetySourceData(id) }, SEND_SAFETY_CENTER_UPDATE)
+
+/**
+ * Calls [SafetyCenterManager.reportSafetySourceError] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.reportSafetySourceErrorWithPermission(
+    safetySourceId: String,
+    safetySourceErrorDetails: SafetySourceErrorDetails
+) =
+    callWithShellPermissionIdentity(
+        { reportSafetySourceError(safetySourceId, safetySourceErrorDetails) },
+        MANAGE_SAFETY_CENTER
+    )
+
+/**
+ * Calls [SafetyCenterManager.refreshSafetySources] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.refreshSafetySourcesWithPermission(refreshReason: Int) =
+    callWithShellPermissionIdentity({ refreshSafetySources(refreshReason) }, MANAGE_SAFETY_CENTER)
+
+/**
+ * Calls [SafetyCenterManager.getSafetyCenterData] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.getSafetyCenterDataWithPermission() =
+    callWithShellPermissionIdentity(::getSafetyCenterData, MANAGE_SAFETY_CENTER)
+
+/**
+ * Calls [SafetyCenterManager.addOnSafetyCenterDataChangedListener] adopting Shell's
+ * [MANAGE_SAFETY_CENTER] permission.
+ */
+fun SafetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
+    executor: Executor,
+    listener: OnSafetyCenterDataChangedListener
+) =
+    callWithShellPermissionIdentity(
+        { addOnSafetyCenterDataChangedListener(executor, listener) },
+        MANAGE_SAFETY_CENTER
+    )
+
+/**
+ * Calls [SafetyCenterManager.removeOnSafetyCenterDataChangedListener] adopting Shell's
+ * [MANAGE_SAFETY_CENTER] permission.
+ */
+fun SafetyCenterManager.removeOnSafetyCenterDataChangedListenerWithPermission(
+    listener: OnSafetyCenterDataChangedListener
+) =
+    callWithShellPermissionIdentity(
+        { removeOnSafetyCenterDataChangedListener(listener) },
+        MANAGE_SAFETY_CENTER
+    )
+
+/**
+ * Calls [SafetyCenterManager.dismissSafetyCenterIssue] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.dismissSafetyIssueWithPermission(safetyCenterIssueId: String) =
+    callWithShellPermissionIdentity({ dismissSafetyCenterIssue(safetyCenterIssueId) },
+        MANAGE_SAFETY_CENTER)
+
+/**
+ * Calls [SafetyCenterManager.executeSafetyCenterIssueAction] adopting Shell's
+ * [MANAGE_SAFETY_CENTER] permission.
+ */
+fun SafetyCenterManager.executeSafetyCenterActionWithPermission(
+    safetyCenterIssueId: String,
+    safetyCenterIssueActionId: String
+) =
+    callWithShellPermissionIdentity(
+        { executeSafetyCenterIssueAction(safetyCenterIssueId, safetyCenterIssueActionId) },
+        MANAGE_SAFETY_CENTER
+    )
+
+/**
+ * Calls [SafetyCenterManager.clearAllSafetySourceData] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.clearAllSafetySourceDataWithPermission() =
+    callWithShellPermissionIdentity({ clearAllSafetySourceData() }, MANAGE_SAFETY_CENTER)
+
+/**
+ * Calls [SafetyCenterManager.setSafetyCenterConfigOverride] adopting Shell's [MANAGE_SAFETY_CENTER]
+ * permission.
+ */
+fun SafetyCenterManager.setSafetyCenterConfigOverrideWithPermission(
+    safetyCenterConfig: SafetyCenterConfig
+) =
+    callWithShellPermissionIdentity(
+        { setSafetyCenterConfigOverride(safetyCenterConfig) },
+        MANAGE_SAFETY_CENTER
+    )
+
+/**
+ * Calls [SafetyCenterManager.clearSafetyCenterConfigOverride] adopting Shell's
+ * [MANAGE_SAFETY_CENTER] permission.
+ */
+fun SafetyCenterManager.clearSafetyCenterConfigOverrideWithPermission() =
+    callWithShellPermissionIdentity({ clearSafetyCenterConfigOverride() }, MANAGE_SAFETY_CENTER)
diff --git a/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterFlags.kt b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterFlags.kt
new file mode 100644
index 0000000..8ae335c
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetyCenterFlags.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.testing
+
+import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.content.Context
+import android.content.res.Resources
+import android.provider.DeviceConfig
+import com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity
+
+/** A class that facilitates working with Safety Center flags. */
+object SafetyCenterFlags {
+
+    /** Name of the flag that determines whether SafetyCenter is enabled. */
+    private const val PROPERTY_SAFETY_CENTER_ENABLED = "safety_center_is_enabled"
+
+    /** Returns whether the device supports Safety Center. */
+    fun Context.deviceSupportsSafetyCenter() =
+        resources.getBoolean(
+            Resources.getSystem().getIdentifier("config_enableSafetyCenter", "bool", "android")
+        )
+
+    /** Sets the Safety Center device config flag to the given boolean [value]. */
+    fun setSafetyCenterEnabled(value: Boolean) {
+        callWithShellPermissionIdentity(
+            {
+                val valueWasSet =
+                    DeviceConfig.setProperty(
+                        DeviceConfig.NAMESPACE_PRIVACY,
+                        PROPERTY_SAFETY_CENTER_ENABLED,
+                        /* value = */ value.toString(),
+                        /* makeDefault = */ false
+                    )
+                if (!valueWasSet) {
+                    throw IllegalStateException("Could not set Safety Center flag value to: $value")
+                }
+            },
+            WRITE_DEVICE_CONFIG
+        )
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/safetycenter/src/android/safetycenter/testing/SafetySourceBroadcastReceiver.kt b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetySourceBroadcastReceiver.kt
new file mode 100644
index 0000000..8097d9d
--- /dev/null
+++ b/tests/cts/safetycenter/src/android/safetycenter/testing/SafetySourceBroadcastReceiver.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2022 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 android.safetycenter.testing
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.safetycenter.SafetyCenterManager
+import android.safetycenter.SafetyCenterManager.ACTION_REFRESH_SAFETY_SOURCES
+import android.safetycenter.SafetyCenterManager.EXTRA_REFRESH_REQUEST_TYPE_FETCH_FRESH_DATA
+import android.safetycenter.SafetyCenterManager.EXTRA_REFRESH_REQUEST_TYPE_GET_DATA
+import android.safetycenter.SafetyCenterManager.EXTRA_REFRESH_SAFETY_SOURCES_REQUEST_TYPE
+import android.safetycenter.SafetyEvent
+import android.safetycenter.SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED
+import android.safetycenter.SafetySourceData
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import java.time.Duration
+
+/** Broadcast receiver to be used for testing broadcasts sent to safety source apps. */
+class SafetySourceBroadcastReceiver : BroadcastReceiver() {
+    override fun onReceive(context: Context, intent: Intent?) {
+        if (intent == null) {
+            throw IllegalArgumentException("Received null intent")
+        }
+
+        if (intent.action != ACTION_REFRESH_SAFETY_SOURCES) {
+            throw IllegalArgumentException("Received intent with action: ${intent.action}")
+        }
+
+        val safetyCenterManager = context.getSystemService(SafetyCenterManager::class.java)!!
+
+        when (intent.getIntExtra(EXTRA_REFRESH_SAFETY_SOURCES_REQUEST_TYPE, -1)) {
+            EXTRA_REFRESH_REQUEST_TYPE_GET_DATA ->
+                safetyCenterManager.setSafetySourceDataWithPermission(
+                    safetySourceId!!,
+                    safetySourceDataOnPageOpen!!,
+                    EVENT_REFRESH_REQUESTED
+                )
+            EXTRA_REFRESH_REQUEST_TYPE_FETCH_FRESH_DATA ->
+                safetyCenterManager.setSafetySourceDataWithPermission(
+                    safetySourceId!!,
+                    safetySourceDataOnRescanClick!!,
+                    EVENT_REFRESH_REQUESTED
+                )
+        }
+
+        runBlocking { updateChannel.send(Unit) }
+    }
+
+    companion object {
+        private val EVENT_REFRESH_REQUESTED =
+            SafetyEvent.Builder(SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("refresh_id")
+                .build()
+
+        @Volatile
+        private var updateChannel = Channel<Unit>()
+
+        @Volatile
+        var safetySourceId: String? = null
+
+        @Volatile
+        var safetySourceDataOnPageOpen: SafetySourceData? = null
+
+        @Volatile
+        var safetySourceDataOnRescanClick: SafetySourceData? = null
+
+        fun reset() {
+            safetySourceId = null
+            safetySourceDataOnRescanClick = null
+            safetySourceDataOnPageOpen = null
+            updateChannel.cancel()
+            updateChannel = Channel()
+        }
+
+        fun waitTillOnReceiveComplete(duration: Duration) {
+            runBlocking { withTimeout(duration.toMillis()) { updateChannel.receive() } }
+        }
+    }
+}