DO NOT MERGE - Merge Android 10 into master

Bug: 139893257
Change-Id: Idf5a1366c365b45a542eb63e110f32c5c1a662ce
diff --git a/Android.bp b/Android.bp
index db94eec..5730600 100644
--- a/Android.bp
+++ b/Android.bp
@@ -12,15 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+android_library {
+    name: "ExtServices-core",
+     srcs: [
+         "src/**/*.java",
+     ],
+     resource_dirs: [
+         "res",
+     ],
+
+     manifest: "AndroidManifest.xml",
+}
+
 android_app {
     name: "ExtServices",
     srcs: ["src/**/*.java"],
     platform_apis: true,
     certificate: "platform",
-    aaptflags: ["--shared-lib"],
-    export_package_resources: true,
     optimize: {
         proguard_flags_files: ["proguard.proguard"],
     },
     privileged: true,
+    min_sdk_version: "28",
 }
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 45e557c..c3f6a65 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -17,11 +17,21 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
     package="android.ext.services"
-    android:versionCode="1"
-    android:versionName="1"
+    android:versionCode="290000000"
+    android:versionName="2019-09"
     coreApp="true">
 
     <uses-permission android:name="android.permission.PROVIDE_RESOLVER_RANKER_SERVICE" />
+    <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
+
+    <uses-permission android:name="android.permission.MONITOR_DEFAULT_SMS_PACKAGE" />
+    <uses-permission android:name="android.permission.REQUEST_NOTIFICATION_ASSISTANT_SERVICE" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <uses-sdk
+        android:minSdkVersion="29"
+        android:targetSdkVersion="29"
+    />
 
     <application android:label="@string/app_name"
         android:defaultToDeviceProtectedStorage="true"
@@ -35,9 +45,8 @@
         </service>
 
         <service android:name=".resolver.LRResolverRankerService"
-                 android:permission="android.permission.BIND_RESOLVER_RANKER_SERVICE"
-                 android:priority="-1" >
-            <intent-filter>
+                 android:permission="android.permission.BIND_RESOLVER_RANKER_SERVICE">
+            <intent-filter android:priority="-1">
                 <action android:name="android.service.resolver.ResolverRankerService" />
             </intent-filter>
         </service>
@@ -64,6 +73,24 @@
                 android:resource="@array/autofill_field_classification_available_algorithms" />
         </service>
 
+        <service android:name=".sms.FinancialSmsServiceImpl"
+                 android:permission="android.permission.BIND_FINANCIAL_SMS_SERVICE">
+            <intent-filter>
+                <action android:name="android.service.sms.action.FINANCIAL_SERVICE_INTENT" />
+            </intent-filter>
+        </service>
+
+        <service android:name=".watchdog.ExplicitHealthCheckServiceImpl"
+                 android:permission="android.permission.BIND_EXPLICIT_HEALTH_CHECK_SERVICE">
+            <intent-filter>
+                <action android:name="android.service.watchdog.ExplicitHealthCheckService" />
+            </intent-filter>
+        </service>
+
+        <activity android:name=".notification.CopyCodeActivity"
+                  android:exported="false"
+                  android:theme="@android:style/Theme.NoDisplay"/>
+
         <library android:name="android.ext.services"/>
     </application>
 
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 72647ab..58deb11 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -17,11 +17,17 @@
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="app_name">Android Services Library</string>
 
-    <string name="notification_assistant">Notification Assistant</string>
-    <string name="prompt_block_reason">Too many dismissals:views</string>
+    <string name="notification_assistant">Android Adaptive Notifications</string>
 
     <string name="autofill_field_classification_default_algorithm">EDIT_DISTANCE</string>
     <string-array name="autofill_field_classification_available_algorithms">
         <item>EDIT_DISTANCE</item>
+        <item>EXACT_MATCH</item>
     </string-array>
+
+    <!-- Action chip to copy a one time code to the user's clipboard [CHAR LIMIT=NONE]-->
+    <string name="copy_code_desc">Copy \u201c<xliff:g id="code" example="12345">%1$s</xliff:g>\u201c</string>
+    <!-- Toast to display when text is copied to the device clipboard [CHAR LIMIT=64]-->
+    <string name="code_copied_to_clipboard">Code copied</string>
+
 </resources>
diff --git a/src/android/ext/services/autofill/AutofillFieldClassificationServiceImpl.java b/src/android/ext/services/autofill/AutofillFieldClassificationServiceImpl.java
index 9ba7e09..e379db8 100644
--- a/src/android/ext/services/autofill/AutofillFieldClassificationServiceImpl.java
+++ b/src/android/ext/services/autofill/AutofillFieldClassificationServiceImpl.java
@@ -15,8 +15,6 @@
  */
 package android.ext.services.autofill;
 
-import static android.ext.services.autofill.EditDistanceScorer.DEFAULT_ALGORITHM;
-
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.os.Bundle;
@@ -26,27 +24,72 @@
 
 import com.android.internal.util.ArrayUtils;
 
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class AutofillFieldClassificationServiceImpl extends AutofillFieldClassificationService {
 
     private static final String TAG = "AutofillFieldClassificationServiceImpl";
 
+    private static final String DEFAULT_ALGORITHM = REQUIRED_ALGORITHM_EDIT_DISTANCE;
+
     @Nullable
     @Override
-    public float[][] onGetScores(@Nullable String algorithmName,
-            @Nullable Bundle algorithmArgs, @NonNull List<AutofillValue> actualValues,
-            @NonNull List<String> userDataValues) {
+    /** @hide */
+    public float[][] onCalculateScores(@NonNull List<AutofillValue> actualValues,
+            @NonNull List<String> userDataValues, @NonNull List<String> categoryIds,
+            @Nullable String defaultAlgorithm, @Nullable Bundle defaultArgs,
+            @Nullable Map algorithms, @Nullable Map args) {
         if (ArrayUtils.isEmpty(actualValues) || ArrayUtils.isEmpty(userDataValues)) {
-            Log.w(TAG, "getScores(): empty currentvalues (" + actualValues + ") or userValues ("
-                    + userDataValues + ")");
+            Log.w(TAG, "calculateScores(): empty currentvalues (" + actualValues
+                    + ") or userValues (" + userDataValues + ")");
             return null;
         }
-        if (algorithmName != null && !algorithmName.equals(DEFAULT_ALGORITHM)) {
-            Log.w(TAG, "Ignoring invalid algorithm (" + algorithmName + ") and using "
-                    + DEFAULT_ALGORITHM + " instead");
-        }
 
-        return EditDistanceScorer.getScores(actualValues, userDataValues);
+        return calculateScores(actualValues, userDataValues, categoryIds, defaultAlgorithm,
+                defaultArgs, (HashMap<String, String>) algorithms,
+                (HashMap<String, Bundle>) args);
+    }
+
+    /** @hide */
+    public float[][] calculateScores(@NonNull List<AutofillValue> actualValues,
+            @NonNull List<String> userDataValues, @NonNull List<String> categoryIds,
+            @Nullable String defaultAlgorithm, @Nullable Bundle defaultArgs,
+            @Nullable HashMap<String, String> algorithms,
+            @Nullable HashMap<String, Bundle> args) {
+        final int actualValuesSize = actualValues.size();
+        final int userDataValuesSize = userDataValues.size();
+        final float[][] scores = new float[actualValuesSize][userDataValuesSize];
+
+        for (int j = 0; j < userDataValuesSize; j++) {
+            final String categoryId = categoryIds.get(j);
+            String algorithmName = defaultAlgorithm;
+            Bundle arg = defaultArgs;
+            if (algorithms != null && algorithms.containsKey(categoryId)) {
+                algorithmName = algorithms.get(categoryId);
+            }
+            if (args != null && args.containsKey(categoryId)) {
+                arg = args.get(categoryId);
+            }
+
+            if (algorithmName == null || (!algorithmName.equals(DEFAULT_ALGORITHM)
+                    && !algorithmName.equals(REQUIRED_ALGORITHM_EXACT_MATCH))) {
+                Log.w(TAG, "algorithmName is " + algorithmName + ", defaulting to "
+                        + DEFAULT_ALGORITHM);
+                algorithmName = DEFAULT_ALGORITHM;
+            }
+
+            for (int i = 0; i < actualValuesSize; i++) {
+                if (algorithmName.equals(DEFAULT_ALGORITHM)) {
+                    scores[i][j] = EditDistanceScorer.calculateScore(actualValues.get(i),
+                            userDataValues.get(j));
+                } else {
+                    scores[i][j] = ExactMatch.calculateScore(actualValues.get(i),
+                            userDataValues.get(j), arg);
+                }
+            }
+        }
+        return scores;
     }
 }
diff --git a/src/android/ext/services/autofill/EditDistanceScorer.java b/src/android/ext/services/autofill/EditDistanceScorer.java
index 302b160..5d14421 100644
--- a/src/android/ext/services/autofill/EditDistanceScorer.java
+++ b/src/android/ext/services/autofill/EditDistanceScorer.java
@@ -17,21 +17,15 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.util.Log;
 import android.view.autofill.AutofillValue;
 
 import com.android.internal.annotations.VisibleForTesting;
 
-import java.util.List;
-
 final class EditDistanceScorer {
 
     private static final String TAG = "EditDistanceScorer";
 
-    // TODO(b/70291841): STOPSHIP - set to false before launching
-    private static final boolean DEBUG = true;
-
-    static final String DEFAULT_ALGORITHM = "EDIT_DISTANCE";
+    private static final boolean DEBUG = false;
 
     /**
      * Gets the field classification score of 2 values based on the edit distance between them.
@@ -39,7 +33,8 @@
      * <p>The score is defined as: @(max_length - edit_distance) / max_length
      */
     @VisibleForTesting
-    static float getScore(@Nullable AutofillValue actualValue, @Nullable String userDataValue) {
+    static float calculateScore(@Nullable AutofillValue actualValue,
+            @Nullable String userDataValue) {
         if (actualValue == null || !actualValue.isText() || userDataValue == null) return 0;
 
         final String actualValueText = actualValue.getTextValue().toString();
@@ -87,7 +82,7 @@
      *     the edit distance is at least as big as the {@code max} parameter
      */
     // Note: copied verbatim from com.android.tools.lint.detector.api.LintUtils.java
-    private static int editDistance(@NonNull String s, @NonNull String t, int max) {
+    public static int editDistance(@NonNull String s, @NonNull String t, int max) {
         if (s.equals(t)) {
             return 0;
         }
@@ -123,26 +118,5 @@
 
         return d[m][n];
     }
-    /**
-     * Gets the scores in a batch.
-     */
-    static float[][] getScores(@NonNull List<AutofillValue> actualValues,
-            @NonNull List<String> userDataValues) {
-        final int actualValuesSize = actualValues.size();
-        final int userDataValuesSize = userDataValues.size();
-        if (DEBUG) {
-            Log.d(TAG, "getScores() will return a " + actualValuesSize + "x"
-                    + userDataValuesSize + " matrix for " + DEFAULT_ALGORITHM);
-        }
-        final float[][] scores = new float[actualValuesSize][userDataValuesSize];
-
-        for (int i = 0; i < actualValuesSize; i++) {
-            for (int j = 0; j < userDataValuesSize; j++) {
-                final float score = getScore(actualValues.get(i), userDataValues.get(j));
-                scores[i][j] = score;
-            }
-        }
-        return scores;
-    }
 
 }
diff --git a/src/android/ext/services/autofill/ExactMatch.java b/src/android/ext/services/autofill/ExactMatch.java
new file mode 100644
index 0000000..3e55c5c
--- /dev/null
+++ b/src/android/ext/services/autofill/ExactMatch.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2018 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.ext.services.autofill;
+
+import android.annotation.Nullable;
+import android.os.Bundle;
+import android.view.autofill.AutofillValue;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+final class ExactMatch {
+
+    /**
+     * Gets the field classification score of 2 values based on whether they are an exact match
+     *
+     * @return {@code 1.0} if the two values are an exact match, {@code 0.0} otherwise.
+     */
+    @VisibleForTesting
+    static float calculateScore(@Nullable AutofillValue actualValue,
+            @Nullable String userDataValue, @Nullable Bundle args) {
+        if (actualValue == null || !actualValue.isText() || userDataValue == null) return 0;
+
+        final String actualValueText = actualValue.getTextValue().toString();
+
+        final int suffixLength;
+        if (args != null) {
+            suffixLength = args.getInt("suffix", -1);
+
+            if (suffixLength < 0) {
+                throw new IllegalArgumentException("suffix argument is invalid");
+            }
+
+            final String actualValueSuffix;
+            if (suffixLength < actualValueText.length()) {
+                actualValueSuffix = actualValueText.substring(actualValueText.length()
+                        - suffixLength);
+            } else {
+                actualValueSuffix = actualValueText;
+            }
+
+            final String userDataValueSuffix;
+            if (suffixLength < userDataValue.length()) {
+                userDataValueSuffix = userDataValue.substring(userDataValue.length()
+                        - suffixLength);
+            } else {
+                userDataValueSuffix = userDataValue;
+            }
+
+            return (actualValueSuffix.equalsIgnoreCase(userDataValueSuffix)) ? 1 : 0;
+        } else {
+            return actualValueText.equalsIgnoreCase(userDataValue) ? 1 : 0;
+        }
+    }
+}
diff --git a/src/android/ext/services/notification/AgingHelper.java b/src/android/ext/services/notification/AgingHelper.java
new file mode 100644
index 0000000..31c9224
--- /dev/null
+++ b/src/android/ext/services/notification/AgingHelper.java
@@ -0,0 +1,172 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.ext.services.notification.NotificationCategorizer.Category;
+import android.net.Uri;
+import android.util.ArraySet;
+import android.util.Slog;
+
+import java.util.Set;
+
+public class AgingHelper {
+    private final static String TAG = "AgingHelper";
+    private final boolean DEBUG = false;
+
+    private static final String AGING_ACTION = AgingHelper.class.getSimpleName() + ".EVALUATE";
+    private static final int REQUEST_CODE_AGING = 1;
+    private static final String AGING_SCHEME = "aging";
+    private static final String EXTRA_KEY = "key";
+    private static final String EXTRA_CATEGORY = "category";
+
+    private static final int HOUR_MS = 1000 * 60 * 60;
+    private static final int TWO_HOURS_MS = 2 * HOUR_MS;
+
+    private Context mContext;
+    private NotificationCategorizer mNotificationCategorizer;
+    private AlarmManager mAm;
+    private Callback mCallback;
+
+    // The set of keys we've scheduled alarms for
+    private Set<String> mAging = new ArraySet<>();
+
+    public AgingHelper(Context context, NotificationCategorizer categorizer, Callback callback) {
+        mNotificationCategorizer = categorizer;
+        mContext = context;
+        mAm = mContext.getSystemService(AlarmManager.class);
+        mCallback = callback;
+
+        IntentFilter filter = new IntentFilter(AGING_ACTION);
+        filter.addDataScheme(AGING_SCHEME);
+        mContext.registerReceiver(mBroadcastReceiver, filter);
+    }
+
+    // NAS lifecycle methods
+
+    public void onNotificationSeen(NotificationEntry entry) {
+        // user has strong opinions about this notification. we can't down rank it, so don't bother.
+        if (entry.getChannel().hasUserSetImportance()) {
+            return;
+        }
+
+        @Category int category = mNotificationCategorizer.getCategory(entry);
+
+        // already very low
+        if (category == NotificationCategorizer.CATEGORY_MIN) {
+            return;
+        }
+
+        if (entry.hasSeen()) {
+            if (category == NotificationCategorizer.CATEGORY_ONGOING
+                    || category > NotificationCategorizer.CATEGORY_REMINDER) {
+                scheduleAging(entry.getSbn().getKey(), category, TWO_HOURS_MS);
+            } else {
+                scheduleAging(entry.getSbn().getKey(), category, HOUR_MS);
+            }
+
+            mAging.add(entry.getSbn().getKey());
+        }
+    }
+
+    public void onNotificationPosted(NotificationEntry entry) {
+        cancelAging(entry.getSbn().getKey());
+    }
+
+    public void onNotificationRemoved(String key) {
+        cancelAging(key);
+    }
+
+    public void onDestroy() {
+        mContext.unregisterReceiver(mBroadcastReceiver);
+    }
+
+    // Aging
+
+    private void scheduleAging(String key, @Category int category, long duration) {
+        if (mAging.contains(key)) {
+            // already scheduled. Don't reset aging just because the user saw the noti again.
+            return;
+        }
+        final PendingIntent pi = createPendingIntent(key, category);
+        long time = System.currentTimeMillis() + duration;
+        if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + key + " in ms: " + duration);
+        mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
+    }
+
+    private void cancelAging(String key) {
+        final PendingIntent pi = createPendingIntent(key);
+        mAm.cancel(pi);
+        mAging.remove(key);
+    }
+
+    private Intent createBaseIntent(String key) {
+        return new Intent(AGING_ACTION)
+                .setData(new Uri.Builder().scheme(AGING_SCHEME).appendPath(key).build());
+    }
+
+    private Intent createAgingIntent(String key, @Category int category) {
+        Intent intent = createBaseIntent(key);
+        intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+                .putExtra(EXTRA_CATEGORY, category)
+                .putExtra(EXTRA_KEY, key);
+        return intent;
+    }
+
+    private PendingIntent createPendingIntent(String key, @Category int category) {
+        return PendingIntent.getBroadcast(mContext,
+                REQUEST_CODE_AGING,
+                createAgingIntent(key, category),
+                PendingIntent.FLAG_UPDATE_CURRENT);
+    }
+
+    private PendingIntent createPendingIntent(String key) {
+        return PendingIntent.getBroadcast(mContext,
+                REQUEST_CODE_AGING,
+                createBaseIntent(key),
+                PendingIntent.FLAG_UPDATE_CURRENT);
+    }
+
+    private void demote(String key, @Category int category) {
+        int newImportance = IMPORTANCE_MIN;
+        // TODO: Change "aged" importance based on category
+        mCallback.sendAdjustment(key, newImportance);
+    }
+
+    protected interface Callback {
+        void sendAdjustment(String key, int newImportance);
+    }
+
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (DEBUG) {
+                Slog.d(TAG, "Reposting notification");
+            }
+            if (AGING_ACTION.equals(intent.getAction())) {
+                demote(intent.getStringExtra(EXTRA_KEY), intent.getIntExtra(EXTRA_CATEGORY,
+                        NotificationCategorizer.CATEGORY_EVERYTHING_ELSE));
+            }
+        }
+    };
+}
diff --git a/src/android/ext/services/notification/Assistant.java b/src/android/ext/services/notification/Assistant.java
index f878822..d01878a 100644
--- a/src/android/ext/services/notification/Assistant.java
+++ b/src/android/ext/services/notification/Assistant.java
@@ -16,21 +16,25 @@
 
 package android.ext.services.notification;
 
+import static android.app.NotificationManager.IMPORTANCE_LOW;
 import static android.app.NotificationManager.IMPORTANCE_MIN;
+import static android.service.notification.Adjustment.KEY_IMPORTANCE;
 import static android.service.notification.NotificationListenerService.Ranking.USER_SENTIMENT_NEGATIVE;
 
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.app.ActivityThread;
 import android.app.INotificationManager;
-import android.content.ContentResolver;
+import android.app.Notification;
+import android.app.NotificationChannel;
 import android.content.Context;
-import android.database.ContentObserver;
-import android.ext.services.R;
-import android.net.Uri;
+import android.content.pm.IPackageManager;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.Handler;
+import android.os.UserHandle;
 import android.os.storage.StorageManager;
-import android.provider.Settings;
 import android.service.notification.Adjustment;
 import android.service.notification.NotificationAssistantService;
 import android.service.notification.NotificationStats;
@@ -41,6 +45,7 @@
 import android.util.Slog;
 import android.util.Xml;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.FastXmlSerializer;
 import com.android.internal.util.XmlUtils;
 
@@ -57,11 +62,15 @@
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 /**
  * Notification assistant that provides guidance on notification channel blocking
  */
+@SuppressLint("OverrideAbstract")
 public class Assistant extends NotificationAssistantService {
     private static final String TAG = "ExtAssistant";
     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
@@ -71,6 +80,7 @@
     private static final String ATT_KEY = "key";
     private static final int DB_VERSION = 1;
     private static final String ATTR_VERSION = "version";
+    private final ExecutorService mSingleThreadExecutor = Executors.newSingleThreadExecutor();
 
     private static final ArrayList<Integer> PREJUDICAL_DISMISSALS = new ArrayList<>();
     static {
@@ -78,17 +88,24 @@
         PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL);
     }
 
-    private float mDismissToViewRatioLimit;
-    private int mStreakLimit;
+    private SmartActionsHelper mSmartActionsHelper;
+    private NotificationCategorizer mNotificationCategorizer;
 
     // key : impressions tracker
     // TODO: prune deleted channels and apps
-    final ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>();
-    // SBN key : channel id
-    ArrayMap<String, String> mLiveNotifications = new ArrayMap<>();
+    private final ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>();
+    // SBN key : entry
+    protected ArrayMap<String, NotificationEntry> mLiveNotifications = new ArrayMap<>();
 
     private Ranking mFakeRanking = null;
     private AtomicFile mFile = null;
+    private IPackageManager mPackageManager;
+
+    @VisibleForTesting
+    protected AssistantSettings.Factory mSettingsFactory = AssistantSettings.FACTORY;
+    @VisibleForTesting
+    protected AssistantSettings mSettings;
+    private SmsHelper mSmsHelper;
 
     public Assistant() {
     }
@@ -98,7 +115,23 @@
         super.onCreate();
         // Contexts are correctly hooked up by the creation step, which is required for the observer
         // to be hooked up/initialized.
-        new SettingsObserver(mHandler);
+        mPackageManager = ActivityThread.getPackageManager();
+        mSettings = mSettingsFactory.createAndRegister(mHandler,
+                getApplicationContext().getContentResolver(), getUserId(), this::updateThresholds);
+        mSmartActionsHelper = new SmartActionsHelper(getContext(), mSettings);
+        mNotificationCategorizer = new NotificationCategorizer();
+        mSmsHelper = new SmsHelper(this);
+        mSmsHelper.initialize();
+    }
+
+    @Override
+    public void onDestroy() {
+        // This null check is only for the unit tests as ServiceTestCase.tearDown calls onDestroy
+        // without having first called onCreate.
+        if (mSmsHelper != null) {
+            mSmsHelper.destroy();
+        }
+        super.onDestroy();
     }
 
     private void loadFile() {
@@ -145,7 +178,7 @@
         }
     }
 
-    private void saveFile() throws IOException {
+    private void saveFile() {
         AsyncTask.execute(() -> {
             final FileOutputStream stream;
             try {
@@ -186,26 +219,92 @@
 
     @Override
     public Adjustment onNotificationEnqueued(StatusBarNotification sbn) {
-        if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey());
+        // we use the version with channel, so this is never called.
         return null;
     }
 
     @Override
+    public Adjustment onNotificationEnqueued(StatusBarNotification sbn,
+            NotificationChannel channel) {
+        if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey() + " on " + channel.getId());
+        if (!isForCurrentUser(sbn)) {
+            return null;
+        }
+        mSingleThreadExecutor.submit(() -> {
+            NotificationEntry entry =
+                    new NotificationEntry(getContext(), mPackageManager, sbn, channel, mSmsHelper);
+            SmartActionsHelper.SmartSuggestions suggestions = mSmartActionsHelper.suggest(entry);
+            if (DEBUG) {
+                Log.d(TAG, String.format(
+                        "Creating Adjustment for %s, with %d actions, and %d replies.",
+                        sbn.getKey(), suggestions.actions.size(), suggestions.replies.size()));
+            }
+            Adjustment adjustment = createEnqueuedNotificationAdjustment(
+                    entry, suggestions.actions, suggestions.replies);
+            adjustNotification(adjustment);
+        });
+        return null;
+    }
+
+    /** A convenience helper for creating an adjustment for an SBN. */
+    @VisibleForTesting
+    @Nullable
+    Adjustment createEnqueuedNotificationAdjustment(
+            @NonNull NotificationEntry entry,
+            @NonNull ArrayList<Notification.Action> smartActions,
+            @NonNull ArrayList<CharSequence> smartReplies) {
+        Bundle signals = new Bundle();
+
+        if (!smartActions.isEmpty()) {
+            signals.putParcelableArrayList(Adjustment.KEY_CONTEXTUAL_ACTIONS, smartActions);
+        }
+        if (!smartReplies.isEmpty()) {
+            signals.putCharSequenceArrayList(Adjustment.KEY_TEXT_REPLIES, smartReplies);
+        }
+        if (mSettings.mNewInterruptionModel) {
+            if (mNotificationCategorizer.shouldSilence(entry)) {
+                final int importance = entry.getImportance() < IMPORTANCE_LOW
+                        ? entry.getImportance() : IMPORTANCE_LOW;
+                signals.putInt(KEY_IMPORTANCE, importance);
+            } else {
+                // Even if no change is made, send an identity adjustment for metric logging.
+                signals.putInt(KEY_IMPORTANCE, entry.getImportance());
+            }
+        }
+
+        return new Adjustment(
+                entry.getSbn().getPackageName(),
+                entry.getSbn().getKey(),
+                signals,
+                "",
+                entry.getSbn().getUserId());
+    }
+
+    @Override
     public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
         if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey());
         try {
+            if (!isForCurrentUser(sbn)) {
+                return;
+            }
             Ranking ranking = getRanking(sbn.getKey(), rankingMap);
             if (ranking != null && ranking.getChannel() != null) {
+                NotificationEntry entry = new NotificationEntry(getContext(), mPackageManager,
+                        sbn, ranking.getChannel(), mSmsHelper);
                 String key = getKey(
                         sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId());
-                ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
-                        createChannelImpressionsWithThresholds());
-                if (ranking.getImportance() > IMPORTANCE_MIN && ci.shouldTriggerBlock()) {
+                boolean shouldTriggerBlock;
+                synchronized (mkeyToImpressions) {
+                    ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
+                            createChannelImpressionsWithThresholds());
+                    mkeyToImpressions.put(key, ci);
+                    shouldTriggerBlock = ci.shouldTriggerBlock();
+                }
+                if (ranking.getImportance() > IMPORTANCE_MIN && shouldTriggerBlock) {
                     adjustNotification(createNegativeAdjustment(
                             sbn.getPackageName(), sbn.getKey(), sbn.getUserId()));
                 }
-                mkeyToImpressions.put(key, ci);
-                mLiveNotifications.put(sbn.getKey(), ranking.getChannel().getId());
+                mLiveNotifications.put(sbn.getKey(), entry);
             }
         } catch (Throwable e) {
             Log.e(TAG, "Error occurred processing post", e);
@@ -216,13 +315,17 @@
     public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
             NotificationStats stats, int reason) {
         try {
+            if (!isForCurrentUser(sbn)) {
+                return;
+            }
+
             boolean updatedImpressions = false;
-            String channelId = mLiveNotifications.remove(sbn.getKey());
+            String channelId = mLiveNotifications.remove(sbn.getKey()).getChannel().getId();
             String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId);
             synchronized (mkeyToImpressions) {
                 ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
                         createChannelImpressionsWithThresholds());
-                if (stats.hasSeen()) {
+                if (stats != null && stats.hasSeen()) {
                     ci.incrementViews();
                     updatedImpressions = true;
                 }
@@ -249,7 +352,7 @@
                 saveFile();
             }
         } catch (Throwable e) {
-            Slog.e(TAG, "Error occurred processing removal", e);
+            Slog.e(TAG, "Error occurred processing removal of " + sbn, e);
         }
     }
 
@@ -259,6 +362,55 @@
     }
 
     @Override
+    public void onNotificationsSeen(List<String> keys) {
+    }
+
+    @Override
+    public void onNotificationExpansionChanged(@NonNull String key, boolean isUserAction,
+            boolean isExpanded) {
+        if (DEBUG) {
+            Log.d(TAG, "onNotificationExpansionChanged() called with: key = [" + key
+                    + "], isUserAction = [" + isUserAction + "], isExpanded = [" + isExpanded
+                    + "]");
+        }
+        NotificationEntry entry = mLiveNotifications.get(key);
+
+        if (entry != null) {
+            mSingleThreadExecutor.submit(
+                    () -> mSmartActionsHelper.onNotificationExpansionChanged(entry, isExpanded));
+        }
+    }
+
+    @Override
+    public void onNotificationDirectReplied(@NonNull String key) {
+        if (DEBUG) Log.i(TAG, "onNotificationDirectReplied " + key);
+        mSingleThreadExecutor.submit(() -> mSmartActionsHelper.onNotificationDirectReplied(key));
+    }
+
+    @Override
+    public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply,
+            @Source int source) {
+        if (DEBUG) {
+            Log.d(TAG, "onSuggestedReplySent() called with: key = [" + key + "], reply = [" + reply
+                    + "], source = [" + source + "]");
+        }
+        mSingleThreadExecutor.submit(
+                () -> mSmartActionsHelper.onSuggestedReplySent(key, reply, source));
+    }
+
+    @Override
+    public void onActionInvoked(@NonNull String key, @NonNull Notification.Action action,
+            @Source int source) {
+        if (DEBUG) {
+            Log.d(TAG,
+                    "onActionInvoked() called with: key = [" + key + "], action = [" + action.title
+                            + "], source = [" + source + "]");
+        }
+        mSingleThreadExecutor.submit(
+                () -> mSmartActionsHelper.onActionClicked(key, action, source));
+    }
+
+    @Override
     public void onListenerConnected() {
         if (DEBUG) Log.i(TAG, "CONNECTED");
         try {
@@ -275,6 +427,14 @@
         }
     }
 
+    @Override
+    public void onListenerDisconnected() {
+    }
+
+    private boolean isForCurrentUser(StatusBarNotification sbn) {
+        return sbn != null && sbn.getUserId() == UserHandle.myUserId();
+    }
+
     protected String getKey(String pkg, int userId, String channelId) {
         return pkg + "|" + userId + "|" + channelId;
     }
@@ -292,35 +452,45 @@
         if (DEBUG) Log.d(TAG, "User probably doesn't want " + key);
         Bundle signals = new Bundle();
         signals.putInt(Adjustment.KEY_USER_SENTIMENT, USER_SENTIMENT_NEGATIVE);
-        return new Adjustment(packageName, key,  signals,
-                getContext().getString(R.string.prompt_block_reason), user);
+        return new Adjustment(packageName, key,  signals, "", user);
     }
 
     // for testing
 
-    protected void setFile(AtomicFile file) {
+    @VisibleForTesting
+    public void setFile(AtomicFile file) {
         mFile = file;
     }
 
-    protected void setFakeRanking(Ranking ranking) {
+    @VisibleForTesting
+    public void setFakeRanking(Ranking ranking) {
         mFakeRanking = ranking;
     }
 
-    protected void setNoMan(INotificationManager noMan) {
+    @VisibleForTesting
+    public void setNoMan(INotificationManager noMan) {
         mNoMan = noMan;
     }
 
-    protected void setContext(Context context) {
+    @VisibleForTesting
+    public void setContext(Context context) {
         mSystemContext = context;
     }
 
-    protected ChannelImpressions getImpressions(String key) {
+    @VisibleForTesting
+    public void setPackageManager(IPackageManager pm) {
+        mPackageManager = pm;
+    }
+
+    @VisibleForTesting
+    public ChannelImpressions getImpressions(String key) {
         synchronized (mkeyToImpressions) {
             return mkeyToImpressions.get(key);
         }
     }
 
-    protected void insertImpressions(String key, ChannelImpressions ci) {
+    @VisibleForTesting
+    public void insertImpressions(String key, ChannelImpressions ci) {
         synchronized (mkeyToImpressions) {
             mkeyToImpressions.put(key, ci);
         }
@@ -328,55 +498,17 @@
 
     private ChannelImpressions createChannelImpressionsWithThresholds() {
         ChannelImpressions impressions = new ChannelImpressions();
-        impressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit);
+        impressions.updateThresholds(mSettings.mDismissToViewRatioLimit, mSettings.mStreakLimit);
         return impressions;
     }
 
-    /**
-     * Observer for updates on blocking helper threshold values.
-     */
-    private final class SettingsObserver extends ContentObserver {
-        private final Uri STREAK_LIMIT_URI =
-                Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT);
-        private final Uri DISMISS_TO_VIEW_RATIO_LIMIT_URI =
-                Settings.Global.getUriFor(
-                        Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT);
-
-        public SettingsObserver(Handler handler) {
-            super(handler);
-            ContentResolver resolver = getApplicationContext().getContentResolver();
-            resolver.registerContentObserver(
-                    DISMISS_TO_VIEW_RATIO_LIMIT_URI, false, this, getUserId());
-            resolver.registerContentObserver(STREAK_LIMIT_URI, false, this, getUserId());
-
-            // Update all uris on creation.
-            update(null);
-        }
-
-        @Override
-        public void onChange(boolean selfChange, Uri uri) {
-            update(uri);
-        }
-
-        private void update(Uri uri) {
-            ContentResolver resolver = getApplicationContext().getContentResolver();
-            if (uri == null || DISMISS_TO_VIEW_RATIO_LIMIT_URI.equals(uri)) {
-                mDismissToViewRatioLimit = Settings.Global.getFloat(
-                        resolver, Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT,
-                        ChannelImpressions.DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT);
-            }
-            if (uri == null || STREAK_LIMIT_URI.equals(uri)) {
-                mStreakLimit = Settings.Global.getInt(
-                        resolver, Settings.Global.BLOCKING_HELPER_STREAK_LIMIT,
-                        ChannelImpressions.DEFAULT_STREAK_LIMIT);
-            }
-
-            // Update all existing channel impression objects with any new limits/thresholds.
-            synchronized (mkeyToImpressions) {
-                for (ChannelImpressions channelImpressions: mkeyToImpressions.values()) {
-                    channelImpressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit);
-                }
+    private void updateThresholds() {
+        // Update all existing channel impression objects with any new limits/thresholds.
+        synchronized (mkeyToImpressions) {
+            for (ChannelImpressions channelImpressions: mkeyToImpressions.values()) {
+                channelImpressions.updateThresholds(
+                        mSettings.mDismissToViewRatioLimit, mSettings.mStreakLimit);
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/src/android/ext/services/notification/AssistantSettings.java b/src/android/ext/services/notification/AssistantSettings.java
new file mode 100644
index 0000000..296db46
--- /dev/null
+++ b/src/android/ext/services/notification/AssistantSettings.java
@@ -0,0 +1,176 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import android.content.ContentResolver;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.DeviceConfig;
+import android.provider.Settings;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
+
+/**
+ * Observes the settings for {@link Assistant}.
+ */
+final class AssistantSettings extends ContentObserver {
+    private static final String LOG_TAG = "AssistantSettings";
+    public static Factory FACTORY = AssistantSettings::createAndRegister;
+    private static final boolean DEFAULT_GENERATE_REPLIES = true;
+    private static final boolean DEFAULT_GENERATE_ACTIONS = true;
+    private static final int DEFAULT_NEW_INTERRUPTION_MODEL_INT = 1;
+    private static final int DEFAULT_MAX_MESSAGES_TO_EXTRACT = 5;
+    @VisibleForTesting
+    static final int DEFAULT_MAX_SUGGESTIONS = 3;
+
+    private static final Uri STREAK_LIMIT_URI =
+            Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT);
+    private static final Uri DISMISS_TO_VIEW_RATIO_LIMIT_URI =
+            Settings.Global.getUriFor(
+                    Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT);
+    private static final Uri NOTIFICATION_NEW_INTERRUPTION_MODEL_URI =
+            Settings.Secure.getUriFor(Settings.Secure.NOTIFICATION_NEW_INTERRUPTION_MODEL);
+
+    private final ContentResolver mResolver;
+    private final int mUserId;
+
+    private final Handler mHandler;
+
+    @VisibleForTesting
+    protected final Runnable mOnUpdateRunnable;
+
+    // Actual configuration settings.
+    float mDismissToViewRatioLimit;
+    int mStreakLimit;
+    boolean mGenerateReplies = DEFAULT_GENERATE_REPLIES;
+    boolean mGenerateActions = DEFAULT_GENERATE_ACTIONS;
+    boolean mNewInterruptionModel;
+    int mMaxMessagesToExtract = DEFAULT_MAX_MESSAGES_TO_EXTRACT;
+    int mMaxSuggestions = DEFAULT_MAX_SUGGESTIONS;
+
+    private AssistantSettings(Handler handler, ContentResolver resolver, int userId,
+            Runnable onUpdateRunnable) {
+        super(handler);
+        mHandler = handler;
+        mResolver = resolver;
+        mUserId = userId;
+        mOnUpdateRunnable = onUpdateRunnable;
+    }
+
+    private static AssistantSettings createAndRegister(
+            Handler handler, ContentResolver resolver, int userId, Runnable onUpdateRunnable) {
+        AssistantSettings assistantSettings =
+                new AssistantSettings(handler, resolver, userId, onUpdateRunnable);
+        assistantSettings.register();
+        assistantSettings.registerDeviceConfigs();
+        return assistantSettings;
+    }
+
+    /**
+     * Creates an instance but doesn't register it as an observer.
+     */
+    @VisibleForTesting
+    protected static AssistantSettings createForTesting(
+            Handler handler, ContentResolver resolver, int userId, Runnable onUpdateRunnable) {
+        return new AssistantSettings(handler, resolver, userId, onUpdateRunnable);
+    }
+
+    private void register() {
+        mResolver.registerContentObserver(
+                DISMISS_TO_VIEW_RATIO_LIMIT_URI, false, this, mUserId);
+        mResolver.registerContentObserver(STREAK_LIMIT_URI, false, this, mUserId);
+
+        // Update all uris on creation.
+        update(null);
+    }
+
+    private void registerDeviceConfigs() {
+        DeviceConfig.addOnPropertiesChangedListener(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                this::postToHandler,
+                (properties) -> onDeviceConfigPropertiesChanged(properties.getNamespace()));
+
+        // Update the fields in this class from the current state of the device config.
+        updateFromDeviceConfigFlags();
+    }
+
+    private void postToHandler(Runnable r) {
+        this.mHandler.post(r);
+    }
+
+    @VisibleForTesting
+    void onDeviceConfigPropertiesChanged(String namespace) {
+        if (!DeviceConfig.NAMESPACE_SYSTEMUI.equals(namespace)) {
+            Log.e(LOG_TAG, "Received update from DeviceConfig for unrelated namespace: "
+                    + namespace);
+            return;
+        }
+
+        updateFromDeviceConfigFlags();
+    }
+
+    private void updateFromDeviceConfigFlags() {
+        mGenerateReplies = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES, DEFAULT_GENERATE_REPLIES);
+
+        mGenerateActions = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS, DEFAULT_GENERATE_ACTIONS);
+
+        mMaxMessagesToExtract = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_MAX_MESSAGES_TO_EXTRACT,
+                DEFAULT_MAX_MESSAGES_TO_EXTRACT);
+
+        mMaxSuggestions = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS);
+
+        mOnUpdateRunnable.run();
+    }
+
+    @Override
+    public void onChange(boolean selfChange, Uri uri) {
+        update(uri);
+    }
+
+    private void update(Uri uri) {
+        if (uri == null || DISMISS_TO_VIEW_RATIO_LIMIT_URI.equals(uri)) {
+            mDismissToViewRatioLimit = Settings.Global.getFloat(
+                    mResolver, Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT,
+                    ChannelImpressions.DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT);
+        }
+        if (uri == null || STREAK_LIMIT_URI.equals(uri)) {
+            mStreakLimit = Settings.Global.getInt(
+                    mResolver, Settings.Global.BLOCKING_HELPER_STREAK_LIMIT,
+                    ChannelImpressions.DEFAULT_STREAK_LIMIT);
+        }
+        if (uri == null || NOTIFICATION_NEW_INTERRUPTION_MODEL_URI.equals(uri)) {
+            int mNewInterruptionModelInt = Settings.Secure.getInt(
+                    mResolver, Settings.Secure.NOTIFICATION_NEW_INTERRUPTION_MODEL,
+                    DEFAULT_NEW_INTERRUPTION_MODEL_INT);
+            mNewInterruptionModel = mNewInterruptionModelInt == 1;
+        }
+
+        mOnUpdateRunnable.run();
+    }
+
+    public interface Factory {
+        AssistantSettings createAndRegister(Handler handler, ContentResolver resolver, int userId,
+                Runnable onUpdateRunnable);
+    }
+}
diff --git a/src/android/ext/services/notification/ChannelImpressions.java b/src/android/ext/services/notification/ChannelImpressions.java
index 29ee920..ca3daad 100644
--- a/src/android/ext/services/notification/ChannelImpressions.java
+++ b/src/android/ext/services/notification/ChannelImpressions.java
@@ -37,6 +37,8 @@
     static final String ATT_DISMISSALS = "dismisses";
     static final String ATT_VIEWS = "views";
     static final String ATT_STREAK = "streak";
+    static final String ATT_SENT = "sent";
+    static final String ATT_INTERRUPTIVE = "interruptive";
 
     private int mDismissals = 0;
     private int mViews = 0;
diff --git a/src/android/ext/services/notification/CopyCodeActivity.java b/src/android/ext/services/notification/CopyCodeActivity.java
new file mode 100644
index 0000000..6708d4d
--- /dev/null
+++ b/src/android/ext/services/notification/CopyCodeActivity.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.ext.services.notification;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Intent;
+import android.ext.services.R;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+/**
+ * An activity that copies text in the Bundle.
+ */
+public class CopyCodeActivity extends Activity {
+    private static final String TAG = "CopyCodeActivity";
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        handleIntent();
+        finish();
+    }
+
+    private void handleIntent() {
+        String code = getIntent().getStringExtra(Intent.EXTRA_TEXT);
+        if (TextUtils.isEmpty(code)) {
+            Log.w(TAG, "handleIntent: empty code");
+            return;
+        }
+        ClipboardManager clipboardManager = getSystemService(ClipboardManager.class);
+        ClipData clipData = ClipData.newPlainText(null, code);
+        clipboardManager.setPrimaryClip(clipData);
+        Toast.makeText(getApplicationContext(), R.string.code_copied_to_clipboard,
+                Toast.LENGTH_SHORT).show();
+    }
+}
diff --git a/src/android/ext/services/notification/EntityTypeCounter.java b/src/android/ext/services/notification/EntityTypeCounter.java
new file mode 100644
index 0000000..50cb0ab
--- /dev/null
+++ b/src/android/ext/services/notification/EntityTypeCounter.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import android.annotation.NonNull;
+import android.util.ArrayMap;
+import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextLinks;
+
+/**
+ * Counts the entity types for smart actions. Some entity types are considered the same
+ * type, like {@link TextClassifier#TYPE_DATE} and {@link TextClassifier#TYPE_DATE_TIME}.
+ */
+class EntityTypeCounter {
+
+    private static final ArrayMap<String, String> ENTITY_TYPE_MAPPING = new ArrayMap<>();
+
+    static {
+        ENTITY_TYPE_MAPPING.put(TextClassifier.TYPE_DATE_TIME, TextClassifier.TYPE_DATE);
+    }
+
+    private final ArrayMap<String, Integer> mEntityTypeCount = new ArrayMap<>();
+
+
+    void increment(@NonNull String entityType) {
+        entityType = convertToBaseEntityType(entityType);
+        if (mEntityTypeCount.containsKey(entityType)) {
+            mEntityTypeCount.put(entityType, mEntityTypeCount.get(entityType) + 1);
+        } else {
+            mEntityTypeCount.put(entityType, 1);
+        }
+    }
+
+    int getCount(@NonNull String entityType) {
+        entityType = convertToBaseEntityType(entityType);
+        return mEntityTypeCount.getOrDefault(entityType, 0);
+    }
+
+    @NonNull
+    private String convertToBaseEntityType(@NonNull String entityType) {
+        return ENTITY_TYPE_MAPPING.getOrDefault(entityType, entityType);
+    }
+
+    /**
+     * Given the links extracted from a piece of text, returns the frequency of each entity
+     * type.
+     */
+    @NonNull
+    static EntityTypeCounter fromTextLinks(@NonNull TextLinks links) {
+        EntityTypeCounter counter = new EntityTypeCounter();
+        for (TextLinks.TextLink link : links.getLinks()) {
+            if (link.getEntityCount() == 0) {
+                continue;
+            }
+            String entityType = link.getEntity(0);
+            counter.increment(entityType);
+        }
+        return counter;
+    }
+}
diff --git a/src/android/ext/services/notification/NotificationCategorizer.java b/src/android/ext/services/notification/NotificationCategorizer.java
new file mode 100644
index 0000000..f95891c
--- /dev/null
+++ b/src/android/ext/services/notification/NotificationCategorizer.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+
+import android.annotation.IntDef;
+import android.app.Notification;
+import android.media.AudioAttributes;
+import android.os.Process;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Default categorizer for incoming notifications; used to determine what notifications
+ * should be silenced.
+ */
+// TODO: stop using @hide methods
+public class NotificationCategorizer {
+
+    protected static final int CATEGORY_MIN = -3;
+    protected static final int CATEGORY_EVERYTHING_ELSE = -2;
+    protected static final int CATEGORY_ONGOING = -1;
+    protected static final int CATEGORY_SYSTEM_LOW = 0;
+    protected static final int CATEGORY_EVENT = 1;
+    protected static final int CATEGORY_REMINDER = 2;
+    protected static final int CATEGORY_SYSTEM = 3;
+    protected static final int CATEGORY_PEOPLE = 4;
+    protected static final int CATEGORY_ALARM = 5;
+    protected static final int CATEGORY_CALL = 6;
+    protected static final int CATEGORY_HIGH = 7;
+
+    /** @hide */
+    @IntDef(prefix = { "CATEGORY_" }, value = {
+            CATEGORY_MIN, CATEGORY_EVERYTHING_ELSE, CATEGORY_ONGOING, CATEGORY_CALL,
+            CATEGORY_SYSTEM_LOW, CATEGORY_EVENT, CATEGORY_REMINDER, CATEGORY_SYSTEM,
+            CATEGORY_PEOPLE, CATEGORY_ALARM, CATEGORY_HIGH
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface Category {}
+
+    public boolean shouldSilence(NotificationEntry entry) {
+        return shouldSilence(getCategory(entry));
+    }
+
+    @VisibleForTesting
+    boolean shouldSilence(int category) {
+        return category < CATEGORY_EVENT;
+    }
+
+    public int getCategory(NotificationEntry entry) {
+        if (entry.getChannel() == null) {
+            return CATEGORY_EVERYTHING_ELSE;
+        }
+        if (entry.getChannel().getImportance() == IMPORTANCE_MIN) {
+            return CATEGORY_MIN;
+        }
+        if (entry.isCategory(Notification.CATEGORY_REMINDER)) {
+            return CATEGORY_REMINDER;
+        }
+        if (entry.isCategory(Notification.CATEGORY_EVENT)) {
+            return CATEGORY_EVENT;
+        }
+        if (entry.isCategory(Notification.CATEGORY_ALARM)
+                || entry.isAudioAttributesUsage(AudioAttributes.USAGE_ALARM)) {
+            return CATEGORY_ALARM;
+        }
+        // TODO: check for default phone app
+        if (entry.isCategory(Notification.CATEGORY_CALL)) {
+            return CATEGORY_CALL;
+        }
+        if (entry.involvesPeople()) {
+            return CATEGORY_PEOPLE;
+        }
+        // TODO: is from signature app
+        if (entry.getSbn().getUid() < Process.FIRST_APPLICATION_UID) {
+            if (entry.getImportance() >= IMPORTANCE_DEFAULT) {
+                return CATEGORY_SYSTEM;
+            } else {
+                return CATEGORY_SYSTEM_LOW;
+            }
+        }
+        if (entry.getChannel().getImportance() == IMPORTANCE_HIGH) {
+            return CATEGORY_HIGH;
+        }
+        if (entry.isOngoing()) {
+            return CATEGORY_ONGOING;
+        }
+        return CATEGORY_EVERYTHING_ELSE;
+    }
+}
diff --git a/src/android/ext/services/notification/NotificationEntry.java b/src/android/ext/services/notification/NotificationEntry.java
new file mode 100644
index 0000000..1ffbac9
--- /dev/null
+++ b/src/android/ext/services/notification/NotificationEntry.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.Notification.CATEGORY_MESSAGE;
+import static android.app.NotificationChannel.USER_LOCKED_IMPORTANCE;
+import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.app.NotificationManager.IMPORTANCE_LOW;
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+import static android.app.NotificationManager.IMPORTANCE_UNSPECIFIED;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.Person;
+import android.app.RemoteInput;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.IPackageManager;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.Icon;
+import android.media.AudioAttributes;
+import android.media.AudioSystem;
+import android.os.Build;
+import android.os.Parcelable;
+import android.os.RemoteException;
+import android.service.notification.StatusBarNotification;
+import android.util.Log;
+import android.util.SparseArray;
+
+import java.util.ArrayList;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Holds data about notifications.
+ */
+public class NotificationEntry {
+    static final String TAG = "NotificationEntry";
+
+    // Copied from hidden definitions in Notification.TvExtender
+    private static final String EXTRA_TV_EXTENDER = "android.tv.EXTENSIONS";
+
+    private final Context mContext;
+    private final StatusBarNotification mSbn;
+    private final IPackageManager mPackageManager;
+    private int mTargetSdkVersion = Build.VERSION_CODES.N_MR1;
+    private final boolean mPreChannelsNotification;
+    private final AudioAttributes mAttributes;
+    private final NotificationChannel mChannel;
+    private final int mImportance;
+    private boolean mSeen;
+    private boolean mIsShowActionEventLogged;
+    private final SmsHelper mSmsHelper;
+
+    private final Object mLock = new Object();
+
+    public NotificationEntry(Context applicationContext, IPackageManager packageManager,
+            StatusBarNotification sbn, NotificationChannel channel, SmsHelper smsHelper) {
+        mContext = applicationContext;
+        mSbn = cloneStatusBarNotificationLight(sbn);
+        mChannel = channel;
+        mPackageManager = packageManager;
+        mPreChannelsNotification = isPreChannelsNotification();
+        mAttributes = calculateAudioAttributes();
+        mImportance = calculateInitialImportance();
+        mSmsHelper = smsHelper;
+    }
+
+    /** Adapted from {@code Notification.lightenPayload}. */
+    @SuppressWarnings("nullness")
+    private static void lightenNotificationPayload(Notification notification) {
+        notification.tickerView = null;
+        notification.contentView = null;
+        notification.bigContentView = null;
+        notification.headsUpContentView = null;
+        notification.largeIcon = null;
+        if (notification.extras != null && !notification.extras.isEmpty()) {
+            final Set<String> keyset = notification.extras.keySet();
+            final int keysetSize = keyset.size();
+            final String[] keys = keyset.toArray(new String[keysetSize]);
+            for (int i = 0; i < keysetSize; i++) {
+                final String key = keys[i];
+                if (EXTRA_TV_EXTENDER.equals(key)
+                        || Notification.EXTRA_MESSAGES.equals(key)
+                        || Notification.EXTRA_MESSAGING_PERSON.equals(key)
+                        || Notification.EXTRA_PEOPLE_LIST.equals(key)) {
+                    continue;
+                }
+                final Object obj = notification.extras.get(key);
+                if (obj != null
+                        && (obj instanceof Parcelable
+                        || obj instanceof Parcelable[]
+                        || obj instanceof SparseArray
+                        || obj instanceof ArrayList)) {
+                    notification.extras.remove(key);
+                }
+            }
+        }
+    }
+
+    /** An interpretation of {@code Notification.cloneInto} with heavy=false. */
+    private Notification cloneNotificationLight(Notification notification) {
+        // We can't just use clone() here because the only way to remove the icons is with the
+        // builder, which we can only create with a Context.
+        Notification lightNotification =
+                Notification.Builder.recoverBuilder(mContext, notification)
+                        .setSmallIcon(0)
+                        .setLargeIcon((Icon) null)
+                        .build();
+        lightenNotificationPayload(lightNotification);
+        return lightNotification;
+    }
+
+    /** Adapted from {@code StatusBarNotification.cloneLight}. */
+    public StatusBarNotification cloneStatusBarNotificationLight(StatusBarNotification sbn) {
+        return new StatusBarNotification(
+                sbn.getPackageName(),
+                sbn.getOpPkg(),
+                sbn.getId(),
+                sbn.getTag(),
+                sbn.getUid(),
+                /*initialPid=*/ 0,
+                /*score=*/ 0,
+                cloneNotificationLight(sbn.getNotification()),
+                sbn.getUser(),
+                sbn.getPostTime());
+    }
+
+    private boolean isPreChannelsNotification() {
+        try {
+            ApplicationInfo info = mPackageManager.getApplicationInfo(
+                    mSbn.getPackageName(), PackageManager.MATCH_ALL,
+                    mSbn.getUserId());
+            if (info != null) {
+                mTargetSdkVersion = info.targetSdkVersion;
+            }
+        } catch (RemoteException e) {
+            Log.w(TAG, "Couldn't look up " + mSbn.getPackageName());
+        }
+        if (NotificationChannel.DEFAULT_CHANNEL_ID.equals(getChannel().getId())) {
+            if (mTargetSdkVersion < Build.VERSION_CODES.O) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private AudioAttributes calculateAudioAttributes() {
+        final Notification n = getNotification();
+        AudioAttributes attributes = getChannel().getAudioAttributes();
+        if (attributes == null) {
+            attributes = Notification.AUDIO_ATTRIBUTES_DEFAULT;
+        }
+
+        if (mPreChannelsNotification
+                && (getChannel().getUserLockedFields()
+                & NotificationChannel.USER_LOCKED_SOUND) == 0) {
+            if (n.audioAttributes != null) {
+                // prefer audio attributes to stream type
+                attributes = n.audioAttributes;
+            } else if (n.audioStreamType >= 0
+                    && n.audioStreamType < AudioSystem.getNumStreamTypes()) {
+                // the stream type is valid, use it
+                attributes = new AudioAttributes.Builder()
+                        .setInternalLegacyStreamType(n.audioStreamType)
+                        .build();
+            } else if (n.audioStreamType != AudioSystem.STREAM_DEFAULT) {
+                Log.w(TAG, String.format("Invalid stream type: %d", n.audioStreamType));
+            }
+        }
+        return attributes;
+    }
+
+    private int calculateInitialImportance() {
+        final Notification n = getNotification();
+        int importance = getChannel().getImportance();
+        int requestedImportance = IMPORTANCE_DEFAULT;
+
+        // Migrate notification flags to scores
+        if ((n.flags & Notification.FLAG_HIGH_PRIORITY) != 0) {
+            n.priority = Notification.PRIORITY_MAX;
+        }
+
+        n.priority = clamp(n.priority, Notification.PRIORITY_MIN,
+                Notification.PRIORITY_MAX);
+        switch (n.priority) {
+            case Notification.PRIORITY_MIN:
+                requestedImportance = IMPORTANCE_MIN;
+                break;
+            case Notification.PRIORITY_LOW:
+                requestedImportance = IMPORTANCE_LOW;
+                break;
+            case Notification.PRIORITY_DEFAULT:
+                requestedImportance = IMPORTANCE_DEFAULT;
+                break;
+            case Notification.PRIORITY_HIGH:
+            case Notification.PRIORITY_MAX:
+                requestedImportance = IMPORTANCE_HIGH;
+                break;
+        }
+
+        if (mPreChannelsNotification
+                && (importance == IMPORTANCE_UNSPECIFIED
+                || (getChannel().getUserLockedFields()
+                & USER_LOCKED_IMPORTANCE) == 0)) {
+            if (n.fullScreenIntent != null) {
+                requestedImportance = IMPORTANCE_HIGH;
+            }
+            importance = requestedImportance;
+        }
+
+        return importance;
+    }
+
+    public boolean isCategory(String category) {
+        return Objects.equals(getNotification().category, category);
+    }
+
+    /**
+     * Similar to {@link #isCategory(String)}, but checking the public version of the notification,
+     * if available.
+     */
+    public boolean isPublicVersionCategory(String category) {
+        Notification publicVersion = getNotification().publicVersion;
+        if (publicVersion == null) {
+            return false;
+        }
+        return Objects.equals(publicVersion.category, category);
+    }
+
+    public boolean isAudioAttributesUsage(int usage) {
+        return mAttributes != null && mAttributes.getUsage() == usage;
+    }
+
+    private boolean hasPerson() {
+        // TODO: cache favorite and recent contacts to check contact affinity
+        ArrayList<Person> people = getNotification().extras.getParcelableArrayList(
+                Notification.EXTRA_PEOPLE_LIST);
+        return people != null && !people.isEmpty();
+    }
+
+    protected boolean hasStyle(Class targetStyle) {
+        Class<? extends Notification.Style> style = getNotification().getNotificationStyle();
+        return targetStyle.equals(style);
+    }
+
+    protected boolean isOngoing() {
+        return (getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;
+    }
+
+    protected boolean involvesPeople() {
+        return isMessaging()
+                || hasStyle(Notification.InboxStyle.class)
+                || hasPerson()
+                || isDefaultSmsApp();
+    }
+
+    private boolean isDefaultSmsApp() {
+        ComponentName defaultSmsApp = mSmsHelper.getDefaultSmsApplication();
+        if (defaultSmsApp == null) {
+            return false;
+        }
+        return mSbn.getPackageName().equals(defaultSmsApp.getPackageName());
+    }
+
+    protected boolean isMessaging() {
+        return isCategory(CATEGORY_MESSAGE)
+                || isPublicVersionCategory(CATEGORY_MESSAGE)
+                || hasStyle(Notification.MessagingStyle.class);
+    }
+
+    public boolean hasInlineReply() {
+        Notification.Action[] actions = getNotification().actions;
+        if (actions == null) {
+            return false;
+        }
+        for (Notification.Action action : actions) {
+            RemoteInput[] remoteInputs = action.getRemoteInputs();
+            if (remoteInputs == null) {
+                continue;
+            }
+            for (RemoteInput remoteInput : remoteInputs) {
+                if (remoteInput.getAllowFreeFormInput()) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    public void setSeen() {
+        synchronized (mLock) {
+            mSeen = true;
+        }
+    }
+
+    public void setShowActionEventLogged() {
+        synchronized (mLock) {
+            mIsShowActionEventLogged = true;
+        }
+    }
+
+    public boolean hasSeen() {
+        synchronized (mLock) {
+            return mSeen;
+        }
+    }
+
+    public boolean isShowActionEventLogged() {
+        synchronized (mLock) {
+            return mIsShowActionEventLogged;
+        }
+    }
+
+    public StatusBarNotification getSbn() {
+        return mSbn;
+    }
+
+    public Notification getNotification() {
+        return getSbn().getNotification();
+    }
+
+    public NotificationChannel getChannel() {
+        return mChannel;
+    }
+
+    public int getImportance() {
+        return mImportance;
+    }
+
+    private int clamp(int x, int low, int high) {
+        return (x < low) ? low : ((x > high) ? high : x);
+    }
+}
diff --git a/src/android/ext/services/notification/SmartActionsHelper.java b/src/android/ext/services/notification/SmartActionsHelper.java
new file mode 100644
index 0000000..93c522e
--- /dev/null
+++ b/src/android/ext/services/notification/SmartActionsHelper.java
@@ -0,0 +1,502 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import android.annotation.Nullable;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Person;
+import android.app.RemoteAction;
+import android.app.RemoteInput;
+import android.content.Context;
+import android.content.Intent;
+import android.ext.services.R;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.os.Process;
+import android.service.notification.NotificationAssistantService;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.LruCache;
+import android.util.Pair;
+import android.view.textclassifier.ConversationAction;
+import android.view.textclassifier.ConversationActions;
+import android.view.textclassifier.TextClassificationContext;
+import android.view.textclassifier.TextClassificationManager;
+import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextClassifierEvent;
+
+import com.android.internal.util.ArrayUtils;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Generates suggestions from incoming notifications.
+ *
+ * Methods in this class should be called in a single worker thread.
+ */
+public class SmartActionsHelper {
+    static final String ENTITIES_EXTRAS = "entities-extras";
+    static final String KEY_ACTION_TYPE = "action_type";
+    static final String KEY_ACTION_SCORE = "action_score";
+    static final String KEY_TEXT = "text";
+    // If a notification has any of these flags set, it's inelgibile for actions being added.
+    private static final int FLAG_MASK_INELGIBILE_FOR_ACTIONS =
+            Notification.FLAG_ONGOING_EVENT
+                    | Notification.FLAG_FOREGROUND_SERVICE
+                    | Notification.FLAG_GROUP_SUMMARY
+                    | Notification.FLAG_NO_CLEAR;
+    private static final int MAX_RESULT_ID_TO_CACHE = 20;
+
+    private static final List<String> HINTS =
+            Collections.singletonList(ConversationActions.Request.HINT_FOR_NOTIFICATION);
+    private static final ConversationActions EMPTY_CONVERSATION_ACTIONS =
+            new ConversationActions(Collections.emptyList(), null);
+
+    private Context mContext;
+    private TextClassificationManager mTextClassificationManager;
+    private AssistantSettings mSettings;
+    private LruCache<String, Session> mSessionCache = new LruCache<>(MAX_RESULT_ID_TO_CACHE);
+
+    SmartActionsHelper(Context context, AssistantSettings settings) {
+        mContext = context;
+        mTextClassificationManager = mContext.getSystemService(TextClassificationManager.class);
+        mSettings = settings;
+    }
+
+    SmartSuggestions suggest(NotificationEntry entry) {
+        // Whenever suggest() is called on a notification, its previous session is ended.
+        mSessionCache.remove(entry.getSbn().getKey());
+
+        boolean eligibleForReplyAdjustment =
+                mSettings.mGenerateReplies && isEligibleForReplyAdjustment(entry);
+        boolean eligibleForActionAdjustment =
+                mSettings.mGenerateActions && isEligibleForActionAdjustment(entry);
+
+        ConversationActions conversationActionsResult =
+                suggestConversationActions(
+                        entry,
+                        eligibleForReplyAdjustment,
+                        eligibleForActionAdjustment);
+
+        String resultId = conversationActionsResult.getId();
+        List<ConversationAction> conversationActions =
+                conversationActionsResult.getConversationActions();
+
+        ArrayList<CharSequence> replies = new ArrayList<>();
+        Map<CharSequence, Float> repliesScore = new ArrayMap<>();
+        for (ConversationAction conversationAction : conversationActions) {
+            CharSequence textReply = conversationAction.getTextReply();
+            if (TextUtils.isEmpty(textReply)) {
+                continue;
+            }
+            replies.add(textReply);
+            repliesScore.put(textReply, conversationAction.getConfidenceScore());
+        }
+
+        ArrayList<Notification.Action> actions = new ArrayList<>();
+        for (ConversationAction conversationAction : conversationActions) {
+            if (!TextUtils.isEmpty(conversationAction.getTextReply())) {
+                continue;
+            }
+            Notification.Action notificationAction;
+            if (conversationAction.getAction() == null) {
+                notificationAction =
+                        createNotificationActionWithoutRemoteAction(conversationAction);
+            } else {
+                notificationAction = createNotificationActionFromRemoteAction(
+                        conversationAction.getAction(),
+                        conversationAction.getType(),
+                        conversationAction.getConfidenceScore());
+            }
+            if (notificationAction != null) {
+                actions.add(notificationAction);
+            }
+        }
+
+        // Start a new session for logging if necessary.
+        if (!TextUtils.isEmpty(resultId)
+                && !conversationActions.isEmpty()
+                && suggestionsMightBeUsedInNotification(
+                entry, !actions.isEmpty(), !replies.isEmpty())) {
+            mSessionCache.put(entry.getSbn().getKey(), new Session(resultId, repliesScore));
+        }
+
+        return new SmartSuggestions(replies, actions);
+    }
+
+    /**
+     * Creates notification action from ConversationAction that does not come up a RemoteAction.
+     * It could happen because we don't have common intents for some actions, like copying text.
+     */
+    @Nullable
+    private Notification.Action createNotificationActionWithoutRemoteAction(
+            ConversationAction conversationAction) {
+        if (ConversationAction.TYPE_COPY.equals(conversationAction.getType())) {
+            return createCopyCodeAction(conversationAction);
+        }
+        return null;
+    }
+
+    @Nullable
+    private Notification.Action createCopyCodeAction(ConversationAction conversationAction) {
+        Bundle extras = conversationAction.getExtras();
+        if (extras == null) {
+            return null;
+        }
+        Bundle entitiesExtas = extras.getParcelable(ENTITIES_EXTRAS);
+        if (entitiesExtas == null) {
+            return null;
+        }
+        String code = entitiesExtas.getString(KEY_TEXT);
+        if (TextUtils.isEmpty(code)) {
+            return null;
+        }
+        String contentDescription = mContext.getString(R.string.copy_code_desc, code);
+        Intent intent = new Intent(mContext, CopyCodeActivity.class);
+        intent.putExtra(Intent.EXTRA_TEXT, code);
+
+        RemoteAction remoteAction = new RemoteAction(Icon.createWithResource(
+                mContext.getResources(),
+                com.android.internal.R.drawable.ic_menu_copy_material),
+                code,
+                contentDescription,
+                PendingIntent.getActivity(
+                        mContext,
+                        code.hashCode(),
+                        intent,
+                        PendingIntent.FLAG_UPDATE_CURRENT
+                ));
+
+        return createNotificationActionFromRemoteAction(
+                remoteAction,
+                ConversationAction.TYPE_COPY,
+                conversationAction.getConfidenceScore());
+    }
+
+    /**
+     * Returns whether the suggestion might be used in the notifications in SysUI.
+     * <p>
+     * Currently, NAS has no idea if suggestions will actually be used in the notification, and thus
+     * this function tries to make a heuristic. This function tries to optimize the precision,
+     * that means when it is unsure, it will return false. The objective is to avoid false positive,
+     * which could pollute the log and CTR as we are logging click rate of suggestions that could
+     * be never visible to users. On the other hand, it is fine to have false negative because
+     * it would be just like sampling.
+     */
+    private boolean suggestionsMightBeUsedInNotification(
+            NotificationEntry notificationEntry, boolean hasSmartAction, boolean hasSmartReply) {
+        Notification notification = notificationEntry.getNotification();
+        boolean hasAppGeneratedContextualActions = !notification.getContextualActions().isEmpty();
+
+        Pair<RemoteInput, Notification.Action> freeformRemoteInputAndAction =
+                notification.findRemoteInputActionPair(/* requiresFreeform */ true);
+        boolean hasAppGeneratedReplies = false;
+        boolean allowGeneratedReplies = false;
+        if (freeformRemoteInputAndAction != null) {
+            RemoteInput freeformRemoteInput = freeformRemoteInputAndAction.first;
+            Notification.Action actionWithFreeformRemoteInput = freeformRemoteInputAndAction.second;
+            hasAppGeneratedReplies = !ArrayUtils.isEmpty(freeformRemoteInput.getChoices());
+            allowGeneratedReplies = actionWithFreeformRemoteInput.getAllowGeneratedReplies();
+        }
+
+        if (hasAppGeneratedReplies || hasAppGeneratedContextualActions) {
+            return false;
+        }
+        return hasSmartAction && notification.getAllowSystemGeneratedContextualActions()
+                || hasSmartReply && allowGeneratedReplies;
+    }
+
+    private void reportActionsGenerated(
+            String resultId, List<ConversationAction> conversationActions) {
+        if (TextUtils.isEmpty(resultId)) {
+            return;
+        }
+        TextClassifierEvent textClassifierEvent =
+                createTextClassifierEventBuilder(
+                        TextClassifierEvent.TYPE_ACTIONS_GENERATED, resultId)
+                        .setEntityTypes(conversationActions.stream()
+                                .map(ConversationAction::getType)
+                                .toArray(String[]::new))
+                        .build();
+        getTextClassifier().onTextClassifierEvent(textClassifierEvent);
+    }
+
+    /**
+     * Adds action adjustments based on the notification contents.
+     */
+    private ConversationActions suggestConversationActions(
+            NotificationEntry entry,
+            boolean includeReplies,
+            boolean includeActions) {
+        if (!includeReplies && !includeActions) {
+            return EMPTY_CONVERSATION_ACTIONS;
+        }
+        List<ConversationActions.Message> messages = extractMessages(entry.getNotification());
+        if (messages.isEmpty()) {
+            return EMPTY_CONVERSATION_ACTIONS;
+        }
+        // Do not generate smart actions if the last message is from the local user.
+        ConversationActions.Message lastMessage = messages.get(messages.size() - 1);
+        if (arePersonsEqual(
+                ConversationActions.Message.PERSON_USER_SELF, lastMessage.getAuthor())) {
+            return EMPTY_CONVERSATION_ACTIONS;
+        }
+
+        TextClassifier.EntityConfig.Builder typeConfigBuilder =
+                new TextClassifier.EntityConfig.Builder();
+        if (!includeReplies) {
+            typeConfigBuilder.setExcludedTypes(
+                    Collections.singletonList(ConversationAction.TYPE_TEXT_REPLY));
+        } else if (!includeActions) {
+            typeConfigBuilder
+                    .setIncludedTypes(
+                            Collections.singletonList(ConversationAction.TYPE_TEXT_REPLY))
+                    .includeTypesFromTextClassifier(false);
+        }
+        ConversationActions.Request request =
+                new ConversationActions.Request.Builder(messages)
+                        .setMaxSuggestions(mSettings.mMaxSuggestions)
+                        .setHints(HINTS)
+                        .setTypeConfig(typeConfigBuilder.build())
+                        .build();
+        ConversationActions conversationActions =
+                getTextClassifier().suggestConversationActions(request);
+        reportActionsGenerated(
+                conversationActions.getId(), conversationActions.getConversationActions());
+        return conversationActions;
+    }
+
+    void onNotificationExpansionChanged(NotificationEntry entry, boolean isExpanded) {
+        if (!isExpanded) {
+            return;
+        }
+        Session session = mSessionCache.get(entry.getSbn().getKey());
+        if (session == null) {
+            return;
+        }
+        // Only report if this is the first time the user sees these suggestions.
+        if (entry.isShowActionEventLogged()) {
+            return;
+        }
+        entry.setShowActionEventLogged();
+        TextClassifierEvent textClassifierEvent =
+                createTextClassifierEventBuilder(
+                        TextClassifierEvent.TYPE_ACTIONS_SHOWN, session.resultId)
+                        .build();
+        // TODO: If possible, report which replies / actions are actually seen by user.
+        getTextClassifier().onTextClassifierEvent(textClassifierEvent);
+    }
+
+    void onNotificationDirectReplied(String key) {
+        Session session = mSessionCache.get(key);
+        if (session == null) {
+            return;
+        }
+        TextClassifierEvent textClassifierEvent =
+                createTextClassifierEventBuilder(
+                        TextClassifierEvent.TYPE_MANUAL_REPLY, session.resultId)
+                        .build();
+        getTextClassifier().onTextClassifierEvent(textClassifierEvent);
+    }
+
+    void onSuggestedReplySent(String key, CharSequence reply,
+            @NotificationAssistantService.Source int source) {
+        if (source != NotificationAssistantService.SOURCE_FROM_ASSISTANT) {
+            return;
+        }
+        Session session = mSessionCache.get(key);
+        if (session == null) {
+            return;
+        }
+        TextClassifierEvent textClassifierEvent =
+                createTextClassifierEventBuilder(
+                        TextClassifierEvent.TYPE_SMART_ACTION, session.resultId)
+                        .setEntityTypes(ConversationAction.TYPE_TEXT_REPLY)
+                        .setScores(session.repliesScores.getOrDefault(reply, 0f))
+                        .build();
+        getTextClassifier().onTextClassifierEvent(textClassifierEvent);
+    }
+
+    void onActionClicked(String key, Notification.Action action,
+            @NotificationAssistantService.Source int source) {
+        if (source != NotificationAssistantService.SOURCE_FROM_ASSISTANT) {
+            return;
+        }
+        Session session = mSessionCache.get(key);
+        if (session == null) {
+            return;
+        }
+        String actionType = action.getExtras().getString(KEY_ACTION_TYPE);
+        if (actionType == null) {
+            return;
+        }
+        TextClassifierEvent textClassifierEvent =
+                createTextClassifierEventBuilder(
+                        TextClassifierEvent.TYPE_SMART_ACTION, session.resultId)
+                        .setEntityTypes(actionType)
+                        .build();
+        getTextClassifier().onTextClassifierEvent(textClassifierEvent);
+    }
+
+    private Notification.Action createNotificationActionFromRemoteAction(
+            RemoteAction remoteAction, String actionType, float score) {
+        Icon icon = remoteAction.shouldShowIcon()
+                ? remoteAction.getIcon()
+                : Icon.createWithResource(mContext, com.android.internal.R.drawable.ic_action_open);
+        Bundle extras = new Bundle();
+        extras.putString(KEY_ACTION_TYPE, actionType);
+        extras.putFloat(KEY_ACTION_SCORE, score);
+        return new Notification.Action.Builder(
+                icon,
+                remoteAction.getTitle(),
+                remoteAction.getActionIntent())
+                .setContextual(true)
+                .addExtras(extras)
+                .build();
+    }
+
+    private TextClassifierEvent.ConversationActionsEvent.Builder createTextClassifierEventBuilder(
+            int eventType, String resultId) {
+        return new TextClassifierEvent.ConversationActionsEvent.Builder(eventType)
+                .setEventContext(
+                        new TextClassificationContext.Builder(
+                                mContext.getPackageName(), TextClassifier.WIDGET_TYPE_NOTIFICATION)
+                        .build())
+                .setResultId(resultId);
+    }
+
+    /**
+     * Returns whether a notification is eligible for action adjustments.
+     *
+     * <p>We exclude system notifications, those that get refreshed frequently, or ones that relate
+     * to fundamental phone functionality where any error would result in a very negative user
+     * experience.
+     */
+    private boolean isEligibleForActionAdjustment(NotificationEntry entry) {
+        Notification notification = entry.getNotification();
+        String pkg = entry.getSbn().getPackageName();
+        if (!Process.myUserHandle().equals(entry.getSbn().getUser())) {
+            return false;
+        }
+        if ((notification.flags & FLAG_MASK_INELGIBILE_FOR_ACTIONS) != 0) {
+            return false;
+        }
+        if (TextUtils.isEmpty(pkg) || pkg.equals("android")) {
+            return false;
+        }
+        // For now, we are only interested in messages.
+        return entry.isMessaging();
+    }
+
+    private boolean isEligibleForReplyAdjustment(NotificationEntry entry) {
+        if (!Process.myUserHandle().equals(entry.getSbn().getUser())) {
+            return false;
+        }
+        String pkg = entry.getSbn().getPackageName();
+        if (TextUtils.isEmpty(pkg) || pkg.equals("android")) {
+            return false;
+        }
+        // For now, we are only interested in messages.
+        if (!entry.isMessaging()) {
+            return false;
+        }
+        // Does not make sense to provide suggested replies if it is not something that can be
+        // replied.
+        if (!entry.hasInlineReply()) {
+            return false;
+        }
+        return true;
+    }
+
+    /** Returns the text most salient for action extraction in a notification. */
+    private List<ConversationActions.Message> extractMessages(Notification notification) {
+        Parcelable[] messages = notification.extras.getParcelableArray(Notification.EXTRA_MESSAGES);
+        if (messages == null || messages.length == 0) {
+            return Collections.singletonList(new ConversationActions.Message.Builder(
+                    ConversationActions.Message.PERSON_USER_OTHERS)
+                    .setText(notification.extras.getCharSequence(Notification.EXTRA_TEXT))
+                    .build());
+        }
+        Person localUser = notification.extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON);
+        Deque<ConversationActions.Message> extractMessages = new ArrayDeque<>();
+        for (int i = messages.length - 1; i >= 0; i--) {
+            Notification.MessagingStyle.Message message =
+                    Notification.MessagingStyle.Message.getMessageFromBundle((Bundle) messages[i]);
+            if (message == null) {
+                continue;
+            }
+            // As per the javadoc of Notification.addMessage, null means local user.
+            Person senderPerson = message.getSenderPerson();
+            if (senderPerson == null) {
+                senderPerson = localUser;
+            }
+            Person author = localUser != null && arePersonsEqual(localUser, senderPerson)
+                    ? ConversationActions.Message.PERSON_USER_SELF : senderPerson;
+            extractMessages.push(new ConversationActions.Message.Builder(author)
+                    .setText(message.getText())
+                    .setReferenceTime(
+                            ZonedDateTime.ofInstant(Instant.ofEpochMilli(message.getTimestamp()),
+                                    ZoneOffset.systemDefault()))
+                    .build());
+            if (extractMessages.size() >= mSettings.mMaxMessagesToExtract) {
+                break;
+            }
+        }
+        return new ArrayList<>(extractMessages);
+    }
+
+    private TextClassifier getTextClassifier() {
+        return mTextClassificationManager.getTextClassifier();
+    }
+
+    private static boolean arePersonsEqual(Person left, Person right) {
+        return Objects.equals(left.getKey(), right.getKey())
+                && Objects.equals(left.getName(), right.getName())
+                && Objects.equals(left.getUri(), right.getUri());
+    }
+
+    static class SmartSuggestions {
+        public final ArrayList<CharSequence> replies;
+        public final ArrayList<Notification.Action> actions;
+
+        SmartSuggestions(
+                ArrayList<CharSequence> replies, ArrayList<Notification.Action> actions) {
+            this.replies = replies;
+            this.actions = actions;
+        }
+    }
+
+    private static class Session {
+        public final String resultId;
+        public final Map<CharSequence, Float> repliesScores;
+
+        Session(String resultId, Map<CharSequence, Float> repliesScores) {
+            this.resultId = resultId;
+            this.repliesScores = repliesScores;
+        }
+    }
+}
diff --git a/src/android/ext/services/notification/SmsHelper.java b/src/android/ext/services/notification/SmsHelper.java
new file mode 100644
index 0000000..07be0b8
--- /dev/null
+++ b/src/android/ext/services/notification/SmsHelper.java
@@ -0,0 +1,75 @@
+/**
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.ext.services.notification;
+
+import static android.provider.Telephony.Sms.Intents.ACTION_DEFAULT_SMS_PACKAGE_CHANGED_INTERNAL;
+
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.util.Log;
+
+import com.android.internal.telephony.SmsApplication;
+
+/**
+ * A helper class for storing and retrieving the default SMS application.
+ */
+public class SmsHelper {
+    private static final String TAG = "SmsHelper";
+
+    private final Context mContext;
+    private ComponentName mDefaultSmsApplication;
+    private BroadcastReceiver mBroadcastReceiver;
+
+    SmsHelper(Context context) {
+        mContext = context.getApplicationContext();
+    }
+
+    void initialize() {
+        if (mBroadcastReceiver == null) {
+            mDefaultSmsApplication = SmsApplication.getDefaultSmsApplication(mContext, false);
+            mBroadcastReceiver = new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    if (ACTION_DEFAULT_SMS_PACKAGE_CHANGED_INTERNAL.equals(intent.getAction())) {
+                        mDefaultSmsApplication =
+                                SmsApplication.getDefaultSmsApplication(mContext, false);
+                    } else {
+                        Log.w(TAG, "Unknown broadcast received: " + intent.getAction());
+                    }
+                }
+            };
+            mContext.registerReceiver(
+                    mBroadcastReceiver,
+                    new IntentFilter(ACTION_DEFAULT_SMS_PACKAGE_CHANGED_INTERNAL));
+        }
+    }
+
+    void destroy() {
+        if (mBroadcastReceiver != null) {
+            mContext.unregisterReceiver(mBroadcastReceiver);
+            mBroadcastReceiver = null;
+        }
+    }
+
+    @Nullable
+    public ComponentName getDefaultSmsApplication() {
+        return mDefaultSmsApplication;
+    }
+}
diff --git a/src/android/ext/services/sms/FinancialSmsServiceImpl.java b/src/android/ext/services/sms/FinancialSmsServiceImpl.java
new file mode 100644
index 0000000..81a63dd
--- /dev/null
+++ b/src/android/ext/services/sms/FinancialSmsServiceImpl.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2018 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.ext.services.sms;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.database.CursorWindow;
+import android.os.Bundle;
+import android.service.sms.FinancialSmsService;
+
+/**
+ * Service to provide financial apps access to sms messages.
+ */
+public class FinancialSmsServiceImpl extends FinancialSmsService {
+
+    private static final String TAG = "FinancialSmsServiceImpl";
+    private static final String KEY_COLUMN_NAMES = "column_names";
+
+    @Nullable
+    @Override
+    public CursorWindow onGetSmsMessages(@NonNull Bundle params) {
+        return null;
+    }
+}
diff --git a/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImpl.java b/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImpl.java
new file mode 100644
index 0000000..670b419
--- /dev/null
+++ b/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImpl.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.ext.services.watchdog;
+
+import static android.service.watchdog.ExplicitHealthCheckService.PackageConfig;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.provider.DeviceConfig;
+import android.service.watchdog.ExplicitHealthCheckService;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Routes explicit health check requests to the appropriate {@link ExplicitHealthChecker}.
+ */
+public final class ExplicitHealthCheckServiceImpl extends ExplicitHealthCheckService {
+    private static final String TAG = "ExplicitHealthCheckServiceImpl";
+    // TODO: Add build dependency on NetworkStack stable AIDL so we can stop hard coding class name
+    private static final String NETWORK_STACK_CONNECTOR_CLASS =
+            "android.net.INetworkStackConnector";
+    public static final String PROPERTY_WATCHDOG_REQUEST_TIMEOUT_MILLIS =
+            "watchdog_request_timeout_millis";
+    public static final long DEFAULT_REQUEST_TIMEOUT_MILLIS =
+            TimeUnit.HOURS.toMillis(1);
+    // Modified only #onCreate, using concurrent collection to ensure thread visibility
+    private final Map<String, ExplicitHealthChecker> mSupportedCheckers = new ConcurrentHashMap<>();
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        initHealthCheckers();
+    }
+
+    @Override
+    public void onRequestHealthCheck(String packageName) {
+        ExplicitHealthChecker checker = mSupportedCheckers.get(packageName);
+        if (checker != null) {
+            checker.request();
+        } else {
+            Log.w(TAG, "Ignoring request for explicit health check for unsupported package "
+                    + packageName);
+        }
+    }
+
+    @Override
+    public void onCancelHealthCheck(String packageName) {
+        ExplicitHealthChecker checker = mSupportedCheckers.get(packageName);
+        if (checker != null) {
+            checker.cancel();
+        } else {
+            Log.w(TAG, "Ignoring request to cancel explicit health check for unsupported package "
+                    + packageName);
+        }
+    }
+
+    @Override
+    public List<PackageConfig> onGetSupportedPackages() {
+        List<PackageConfig> packages = new ArrayList<>();
+        long requestTimeoutMillis = DeviceConfig.getLong(
+                DeviceConfig.NAMESPACE_ROLLBACK,
+                PROPERTY_WATCHDOG_REQUEST_TIMEOUT_MILLIS,
+                DEFAULT_REQUEST_TIMEOUT_MILLIS);
+        if (requestTimeoutMillis <= 0) {
+            requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MILLIS;
+        }
+        for (ExplicitHealthChecker checker : mSupportedCheckers.values()) {
+            PackageConfig pkg = new PackageConfig(checker.getSupportedPackageName(),
+                    requestTimeoutMillis);
+            packages.add(pkg);
+        }
+        return packages;
+    }
+
+    @Override
+    public List<String> onGetRequestedPackages() {
+        List<String> packages = new ArrayList<>();
+        Iterator<ExplicitHealthChecker> it = mSupportedCheckers.values().iterator();
+        // Could potentially race, where we read a checker#isPending and it changes before we
+        // return list. However, if it races and it is in the list, the caller might call #cancel
+        // which would fail, but that is fine. If it races and it ends up *not* in the list, it was
+        // already cancelled, so there's no need for the caller to cancel it
+        while (it.hasNext()) {
+            ExplicitHealthChecker checker = it.next();
+            if (checker.isPending()) {
+                packages.add(checker.getSupportedPackageName());
+            }
+        }
+        return packages;
+    }
+
+    private void initHealthCheckers() {
+        Intent intent = new Intent(NETWORK_STACK_CONNECTOR_CLASS);
+        ComponentName comp = intent.resolveSystemService(getPackageManager(), 0);
+        if (comp != null) {
+            String networkStackPackageName = comp.getPackageName();
+            mSupportedCheckers.put(networkStackPackageName,
+                    new NetworkChecker(this, networkStackPackageName));
+        } else {
+            // On Go devices, or any device that does not ship the network stack module.
+            // The network stack will live in system_server process, so no need to monitor.
+            Log.i(TAG, "Network stack module not found");
+        }
+    }
+}
diff --git a/src/android/ext/services/watchdog/ExplicitHealthChecker.java b/src/android/ext/services/watchdog/ExplicitHealthChecker.java
new file mode 100644
index 0000000..a982d52
--- /dev/null
+++ b/src/android/ext/services/watchdog/ExplicitHealthChecker.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.ext.services.watchdog;
+
+/**
+ * A type of explicit health check that can be performed on a device, e.g network health check
+ */
+interface ExplicitHealthChecker {
+    /**
+     * Requests a checker to listen to explicit health checks for {@link #getPackageName}.
+     *  {@link #isPending} will now return {@code true}.
+     */
+    void request();
+
+    /**
+     * Cancels a pending explicit health check request for {@link #getPackageName}.
+     * {@link #isPending} will now return {@code false}.
+     */
+    void cancel();
+
+    /**
+     * Returns {@code true} if a request is pending, {@code false} otherwise.
+     */
+    boolean isPending();
+
+    /**
+     * Returns the name of the package this checker can make requests for.
+     */
+    String getSupportedPackageName();
+}
diff --git a/src/android/ext/services/watchdog/NetworkChecker.java b/src/android/ext/services/watchdog/NetworkChecker.java
new file mode 100644
index 0000000..5722e09
--- /dev/null
+++ b/src/android/ext/services/watchdog/NetworkChecker.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.ext.services.watchdog;
+
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.service.watchdog.ExplicitHealthCheckService;
+
+import com.android.internal.annotations.GuardedBy;
+
+/**
+ * Observes the network stack via the ConnectivityManager.
+ */
+final class NetworkChecker extends ConnectivityManager.NetworkCallback
+        implements ExplicitHealthChecker {
+    private static final String TAG = "NetworkChecker";
+
+    private final Object mLock = new Object();
+    private final ExplicitHealthCheckService mService;
+    private final String mPackageName;
+    @GuardedBy("mLock")
+    private boolean mIsPending;
+
+    NetworkChecker(ExplicitHealthCheckService service, String packageName) {
+        mService = service;
+        mPackageName = packageName;
+    }
+
+    @Override
+    public void request() {
+        synchronized (mLock) {
+            if (mIsPending) {
+                return;
+            }
+            mService.getSystemService(ConnectivityManager.class).registerNetworkCallback(
+                    new NetworkRequest.Builder().build(), this);
+            mIsPending = true;
+        }
+    }
+
+    @Override
+    public void cancel() {
+        synchronized (mLock) {
+            if (!mIsPending) {
+                return;
+            }
+            mService.getSystemService(ConnectivityManager.class).unregisterNetworkCallback(this);
+            mIsPending = false;
+        }
+    }
+
+    @Override
+    public boolean isPending() {
+        synchronized (mLock) {
+            return mIsPending;
+        }
+    }
+
+    @Override
+    public String getSupportedPackageName() {
+        return mPackageName;
+    }
+
+    // TODO(b/120598832): Also monitor NetworkCallback#onAvailable to see if we have any
+    // available networks that may be unusable. This could be additional signal to our heuristics
+    @Override
+    public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) {
+        synchronized (mLock) {
+            if (mIsPending
+                    && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
+                mService.notifyHealthCheckPassed(mPackageName);
+                cancel();
+            }
+        }
+    }
+}
diff --git a/tests/Android.bp b/tests/Android.bp
index db16027..ae60d4e 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -1,19 +1,27 @@
 android_test {
     name: "ExtServicesUnitTests",
+
+    // Include all test java files.
+    srcs: ["src/**/*.java"],
+
+    // We only want this apk build for tests.
     certificate: "platform",
+
     libs: [
         "android.test.runner",
         "android.test.base",
     ],
+
     static_libs: [
+        "ExtServices-core",
         "androidx.test.rules",
+        "compatibility-device-util-axt",
         "mockito-target-minus-junit4",
         "androidx.test.espresso.core",
         "truth-prebuilt",
         "testables",
+        "testng",
     ],
-    // Include all test java files.
-    srcs: ["src/**/*.java"],
+
     platform_apis: true,
-    instrumentation_for: "ExtServices",
 }
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
index 42293b5..a08a10e 100644
--- a/tests/AndroidManifest.xml
+++ b/tests/AndroidManifest.xml
@@ -24,7 +24,7 @@
     </application>
 
     <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
-                     android:targetPackage="android.ext.services"
+                     android:targetPackage="android.ext.services.tests.unit"
                      android:label="ExtServices Test Cases">
     </instrumentation>
 
diff --git a/tests/src/android/ext/services/autofill/AutofillFieldClassificationServiceImplTest.java b/tests/src/android/ext/services/autofill/AutofillFieldClassificationServiceImplTest.java
index 48c076e..6fda4c7 100644
--- a/tests/src/android/ext/services/autofill/AutofillFieldClassificationServiceImplTest.java
+++ b/tests/src/android/ext/services/autofill/AutofillFieldClassificationServiceImplTest.java
@@ -16,14 +16,21 @@
 
 package android.ext.services.autofill;
 
+import static android.service.autofill.AutofillFieldClassificationService.REQUIRED_ALGORITHM_EXACT_MATCH;
+import static android.view.autofill.AutofillValue.forText;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.os.Bundle;
+import android.view.autofill.AutofillValue;
+
 import org.junit.Test;
 
 import java.util.Arrays;
 import java.util.Collections;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import android.view.autofill.AutofillValue;
+import java.util.HashMap;
+import java.util.List;
 
 /**
  * Contains the base tests that does not rely on the specific algorithm implementation.
@@ -34,26 +41,78 @@
             new AutofillFieldClassificationServiceImpl();
 
     @Test
-    public void testOnGetScores_nullActualValues() {
-        assertThat(mService.onGetScores(null, null, null, Arrays.asList("whatever"))).isNull();
+    public void testOnCalculateScores_nullActualValues() {
+        assertThat(mService.onCalculateScores(null, null, null, null, null, null, null)).isNull();
     }
 
     @Test
-    public void testOnGetScores_emptyActualValues() {
-        assertThat(mService.onGetScores(null, null, Collections.emptyList(),
-                Arrays.asList("whatever"))).isNull();
+    public void testOnCalculateScores_emptyActualValues() {
+        assertThat(mService.onCalculateScores(Collections.emptyList(), Arrays.asList("whatever"),
+                null, null, null, null, null)).isNull();
     }
 
     @Test
-    public void testOnGetScores_nullUserDataValues() {
-        assertThat(mService.onGetScores(null, null,
-                Arrays.asList(AutofillValue.forText("whatever")), null)).isNull();
+    public void testOnCalculateScores_nullUserDataValues() {
+        assertThat(mService.onCalculateScores(Arrays.asList(AutofillValue.forText("whatever")),
+                null, null, null, null, null, null)).isNull();
     }
 
     @Test
-    public void testOnGetScores_emptyUserDataValues() {
-        assertThat(mService.onGetScores(null, null,
-                Arrays.asList(AutofillValue.forText("whatever")), Collections.emptyList()))
-                        .isNull();
+    public void testOnCalculateScores_emptyUserDataValues() {
+        assertThat(mService.onCalculateScores(Arrays.asList(AutofillValue.forText("whatever")),
+                Collections.emptyList(), null, null, null, null, null))
+                .isNull();
+    }
+
+    @Test
+    public void testCalculateScores() {
+        final List<AutofillValue> actualValues = Arrays.asList(forText("A"), forText("b"),
+                forText("dude"));
+        final List<String> userDataValues = Arrays.asList("a", "b", "B", "ab", "c", "dude",
+                "sweet_dude", "dude_sweet");
+        final List<String> categoryIds = Arrays.asList("cat", "cat", "cat", "cat", "cat", "last4",
+                "last4", "last4");
+        final HashMap<String, String> algorithms = new HashMap<>(1);
+        algorithms.put("last4", REQUIRED_ALGORITHM_EXACT_MATCH);
+
+        final Bundle last4Bundle = new Bundle();
+        last4Bundle.putInt("suffix", 4);
+
+        final HashMap<String, Bundle> args = new HashMap<>(1);
+        args.put("last4", last4Bundle);
+
+        final float[][] expectedScores = new float[][] {
+                new float[] { 1F, 0F, 0F, 0.5F, 0F, 0F, 0F, 0F },
+                new float[] { 0F, 1F, 1F, 0.5F, 0F, 0F, 0F, 0F },
+                new float[] { 0F, 0F, 0F, 0F  , 0F, 1F, 1F, 0F }
+        };
+        final float[][] actualScores = mService.onCalculateScores(actualValues, userDataValues,
+                categoryIds, null, null, algorithms, args);
+
+        // Unfortunately, Truth does not have an easy way to compare float matrices and show useful
+        // messages in case of error, so we need to check.
+        assertWithMessage("actual=%s, expected=%s", toString(actualScores),
+                toString(expectedScores)).that(actualScores.length).isEqualTo(3);
+        for (int i = 0; i < 3; i++) {
+            assertWithMessage("actual=%s, expected=%s", toString(actualScores),
+                    toString(expectedScores)).that(actualScores[i].length).isEqualTo(8);
+        }
+
+        for (int i = 0; i < actualScores.length; i++) {
+            final float[] line = actualScores[i];
+            for (int j = 0; j < line.length; j++) {
+                float cell = line[j];
+                assertWithMessage("wrong score at [%s, %s]", i, j).that(cell).isWithin(0.01F)
+                        .of(expectedScores[i][j]);
+            }
+        }
+    }
+
+    public static String toString(float[][] matrix) {
+        final StringBuilder string = new StringBuilder("[ ");
+        for (int i = 0; i < matrix.length; i++) {
+            string.append(Arrays.toString(matrix[i])).append(" ");
+        }
+        return string.append(" ]").toString();
     }
 }
diff --git a/tests/src/android/ext/services/autofill/EditDistanceScorerTest.java b/tests/src/android/ext/services/autofill/EditDistanceScorerTest.java
index afe2236..3d754f7 100644
--- a/tests/src/android/ext/services/autofill/EditDistanceScorerTest.java
+++ b/tests/src/android/ext/services/autofill/EditDistanceScorerTest.java
@@ -15,107 +15,71 @@
  */
 package android.ext.services.autofill;
 
-import static android.ext.services.autofill.EditDistanceScorer.getScore;
-import static android.ext.services.autofill.EditDistanceScorer.getScores;
-import static android.view.autofill.AutofillValue.forText;
+import static android.ext.services.autofill.EditDistanceScorer.calculateScore;
+import static android.ext.services.autofill.EditDistanceScorer.editDistance;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 
 import android.view.autofill.AutofillValue;
 
 import org.junit.Test;
 
-import java.util.Arrays;
-import java.util.List;
-
 public class EditDistanceScorerTest {
 
     @Test
-    public void testGetScore_nullValue() {
-        assertFloat(getScore(null, "D'OH!"), 0);
+    public void testCalculateScore_nullValue() {
+        assertFloat(calculateScore(null, "D'OH!"), 0);
     }
 
     @Test
-    public void testGetScore_nonTextValue() {
-        assertFloat(getScore(AutofillValue.forToggle(true), "D'OH!"), 0);
+    public void testCalculateScore_nonTextValue() {
+        assertFloat(calculateScore(AutofillValue.forToggle(true), "D'OH!"), 0);
     }
 
     @Test
-    public void testGetScore_nullUserData() {
-        assertFloat(getScore(AutofillValue.forText("D'OH!"), null), 0);
+    public void testCalculateScore_nullUserData() {
+        assertFloat(calculateScore(AutofillValue.forText("D'OH!"), null), 0);
     }
 
     @Test
-    public void testGetScore_fullMatch() {
-        assertFloat(getScore(AutofillValue.forText("D'OH!"), "D'OH!"), 1);
-        assertFloat(getScore(AutofillValue.forText(""), ""), 1);
+    public void testCalculateScore_fullMatch() {
+        assertFloat(calculateScore(AutofillValue.forText("D'OH!"), "D'OH!"), 1);
+        assertFloat(calculateScore(AutofillValue.forText(""), ""), 1);
     }
 
     @Test
-    public void testGetScore_fullMatchMixedCase() {
-        assertFloat(getScore(AutofillValue.forText("D'OH!"), "D'oH!"), 1);
+    public void testCalculateScore_fullMatchMixedCase() {
+        assertFloat(calculateScore(AutofillValue.forText("D'OH!"), "D'oH!"), 1);
     }
 
     @Test
-    public void testGetScore_mismatchDifferentSizes() {
-        assertFloat(getScore(AutofillValue.forText("X"), "Xy"), 0.50F);
-        assertFloat(getScore(AutofillValue.forText("Xy"), "X"), 0.50F);
-        assertFloat(getScore(AutofillValue.forText("One"), "MoreThanOne"), 0.27F);
-        assertFloat(getScore(AutofillValue.forText("MoreThanOne"), "One"), 0.27F);
-        assertFloat(getScore(AutofillValue.forText("1600 Amphitheatre Parkway"),
+    public void testCalculateScore_mismatchDifferentSizes() {
+        assertFloat(calculateScore(AutofillValue.forText("X"), "Xy"), 0.50F);
+        assertFloat(calculateScore(AutofillValue.forText("Xy"), "X"), 0.50F);
+        assertFloat(calculateScore(AutofillValue.forText("One"), "MoreThanOne"), 0.27F);
+        assertFloat(calculateScore(AutofillValue.forText("MoreThanOne"), "One"), 0.27F);
+        assertFloat(calculateScore(AutofillValue.forText("1600 Amphitheatre Parkway"),
                 "1600 Amphitheatre Pkwy"), 0.88F);
-        assertFloat(getScore(AutofillValue.forText("1600 Amphitheatre Pkwy"),
+        assertFloat(calculateScore(AutofillValue.forText("1600 Amphitheatre Pkwy"),
                 "1600 Amphitheatre Parkway"), 0.88F);
     }
 
     @Test
-    public void testGetScore_partialMatch() {
-        assertFloat(getScore(AutofillValue.forText("Dude"), "Dxxx"), 0.25F);
-        assertFloat(getScore(AutofillValue.forText("Dude"), "DUxx"), 0.50F);
-        assertFloat(getScore(AutofillValue.forText("Dude"), "DUDx"), 0.75F);
-        assertFloat(getScore(AutofillValue.forText("Dxxx"), "Dude"), 0.25F);
-        assertFloat(getScore(AutofillValue.forText("DUxx"), "Dude"), 0.50F);
-        assertFloat(getScore(AutofillValue.forText("DUDx"), "Dude"), 0.75F);
+    public void testCalculateScore_partialMatch() {
+        assertFloat(calculateScore(AutofillValue.forText("Dude"), "Dxxx"), 0.25F);
+        assertFloat(calculateScore(AutofillValue.forText("Dude"), "DUxx"), 0.50F);
+        assertFloat(calculateScore(AutofillValue.forText("Dude"), "DUDx"), 0.75F);
+        assertFloat(calculateScore(AutofillValue.forText("Dxxx"), "Dude"), 0.25F);
+        assertFloat(calculateScore(AutofillValue.forText("DUxx"), "Dude"), 0.50F);
+        assertFloat(calculateScore(AutofillValue.forText("DUDx"), "Dude"), 0.75F);
     }
 
     @Test
-    public void testGetScores() {
-        final List<AutofillValue> actualValues = Arrays.asList(forText("A"), forText("b"));
-        final List<String> userDataValues = Arrays.asList("a", "B", "ab", "c");
-        final float[][] expectedScores = new float[][] {
-            new float[] { 1F, 0F, 0.5F, 0F },
-            new float[] { 0F, 1F, 0.5F, 0F }
-        };
-        final float[][] actualScores = getScores(actualValues, userDataValues);
-
-        // Unfortunately, Truth does not have an easy way to compare float matrices and show useful
-        // messages in case of error, so we need to check.
-        assertWithMessage("actual=%s, expected=%s", toString(actualScores),
-                toString(expectedScores)).that(actualScores.length).isEqualTo(2);
-        assertWithMessage("actual=%s, expected=%s", toString(actualScores),
-                toString(expectedScores)).that(actualScores[0].length).isEqualTo(4);
-        assertWithMessage("actual=%s, expected=%s", toString(actualScores),
-                toString(expectedScores)).that(actualScores[1].length).isEqualTo(4);
-        for (int i = 0; i < actualScores.length; i++) {
-            final float[] line = actualScores[i];
-            for (int j = 0; j < line.length; j++) {
-                float cell = line[j];
-                assertWithMessage("wrong score at [%s, %s]", i, j).that(cell).isWithin(0.01F)
-                        .of(expectedScores[i][j]);
-            }
-        }
+    public void testEditDistance_maxDistance() {
+        assertFloat(editDistance("testing", "b", 4), Integer.MAX_VALUE);
     }
 
     public static void assertFloat(float actualValue, float expectedValue) {
         assertThat(actualValue).isWithin(0.01F).of(expectedValue);
     }
-
-    public static String toString(float[][] matrix) {
-        final StringBuilder string = new StringBuilder("[ ");
-        for (int i = 0; i < matrix.length; i++) {
-            string.append(Arrays.toString(matrix[i])).append(" ");
-        }
-        return string.append(" ]").toString();
-    }
 }
diff --git a/tests/src/android/ext/services/autofill/ExactMatchTest.java b/tests/src/android/ext/services/autofill/ExactMatchTest.java
new file mode 100644
index 0000000..bf5e160
--- /dev/null
+++ b/tests/src/android/ext/services/autofill/ExactMatchTest.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2018 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.ext.services.autofill;
+
+import static android.ext.services.autofill.ExactMatch.calculateScore;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.os.Bundle;
+import android.view.autofill.AutofillValue;
+
+import org.junit.Test;
+
+public class ExactMatchTest {
+
+    private Bundle last4Bundle() {
+        final Bundle bundle = new Bundle();
+        bundle.putInt("suffix", 4);
+        return bundle;
+    }
+
+    @Test
+    public void testCalculateScore_nullValue() {
+        assertFloat(calculateScore(null, "TEST", null), 0);
+    }
+
+    @Test
+    public void testCalculateScore_nonTextValue() {
+        assertFloat(calculateScore(AutofillValue.forToggle(true), "TEST", null), 0);
+    }
+
+    @Test
+    public void testCalculateScore_nullUserData() {
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), null, null), 0);
+    }
+
+    @Test
+    public void testCalculateScore_succeedMatchMixedCases_last4() {
+        final Bundle last4 = last4Bundle();
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "1234 test", last4), 1);
+        assertFloat(calculateScore(AutofillValue.forText("test"), "1234 TEST", last4), 1);
+    }
+
+    @Test
+    public void testCalculateScore_mismatchDifferentSizes_last4() {
+        final Bundle last4 = last4Bundle();
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "TEST1", last4), 0);
+        assertFloat(calculateScore(AutofillValue.forText(""), "TEST", last4), 0);
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "", last4), 0);
+    }
+
+    @Test
+    public void testCalculateScore_match() {
+        final Bundle last4 = last4Bundle();
+        assertFloat(calculateScore(AutofillValue.forText("1234 1234 1234 1234"),
+                "xxxx xxxx xxxx 1234", last4), 1);
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "TEST", null), 1);
+        assertFloat(calculateScore(AutofillValue.forText("TEST 1234"), "1234", last4), 1);
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "test", null), 1);
+    }
+
+    @Test
+    public void testCalculateScore_badBundle() {
+        final Bundle bundle = new Bundle();
+        bundle.putInt("suffix", -2);
+        assertThrows(IllegalArgumentException.class, () -> calculateScore(
+                AutofillValue.forText("TEST"), "TEST", bundle));
+
+        final Bundle largeBundle = new Bundle();
+        largeBundle.putInt("suffix", 10);
+        assertFloat(calculateScore(AutofillValue.forText("TEST"), "TEST", largeBundle), 1);
+
+        final Bundle stringBundle = new Bundle();
+        stringBundle.putString("suffix", "value");
+        assertThrows(IllegalArgumentException.class, () -> calculateScore(
+                AutofillValue.forText("TEST"), "TEST", stringBundle));
+
+    }
+
+    public static void assertFloat(float actualValue, float expectedValue) {
+        assertThat(actualValue).isWithin(0.01F).of(expectedValue);
+    }
+}
diff --git a/tests/src/android/ext/services/notification/AgingHelperTest.java b/tests/src/android/ext/services/notification/AgingHelperTest.java
new file mode 100644
index 0000000..a87d57c
--- /dev/null
+++ b/tests/src/android/ext/services/notification/AgingHelperTest.java
@@ -0,0 +1,160 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.IPackageManager;
+import android.os.Build;
+import android.os.Process;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.testing.TestableContext;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+public class AgingHelperTest {
+    private String mPkg = "pkg";
+    private int mUid = 2018;
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getTargetContext(), null);
+
+    @Mock
+    private NotificationCategorizer mCategorizer;
+    @Mock
+    private AlarmManager mAlarmManager;
+    @Mock
+    private IPackageManager mPackageManager;
+    @Mock
+    private AgingHelper.Callback mCallback;
+    @Mock
+    private SmsHelper mSmsHelper;
+
+    private AgingHelper mAgingHelper;
+
+    private StatusBarNotification generateSbn(String channelId) {
+        Notification n = new Notification.Builder(mContext, channelId)
+                .setContentTitle("foo")
+                .build();
+
+        return new StatusBarNotification(mPkg, mPkg, 0, "tag", mUid, mUid, n,
+                UserHandle.SYSTEM, null, 0);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mPkg = mContext.getPackageName();
+        mUid = Process.myUid();
+
+        ApplicationInfo info = mock(ApplicationInfo.class);
+        when(mPackageManager.getApplicationInfo(anyString(), anyInt(), anyInt()))
+                .thenReturn(info);
+        info.targetSdkVersion = Build.VERSION_CODES.P;
+
+        mContext.addMockSystemService(AlarmManager.class, mAlarmManager);
+
+        mAgingHelper = new AgingHelper(mContext, mCategorizer, mCallback);
+    }
+
+    @Test
+    public void testNoSnoozingOnPost() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+
+
+        mAgingHelper.onNotificationPosted(entry);
+        verify(mAlarmManager, never()).setExactAndAllowWhileIdle(anyInt(), anyLong(), any());
+    }
+
+    @Test
+    public void testPostResetsSnooze() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+
+
+        mAgingHelper.onNotificationPosted(entry);
+        verify(mAlarmManager, times(1)).cancel(any(PendingIntent.class));
+    }
+
+    @Test
+    public void testSnoozingOnSeen() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        entry.setSeen();
+        when(mCategorizer.getCategory(entry)).thenReturn(NotificationCategorizer.CATEGORY_PEOPLE);
+
+        mAgingHelper.onNotificationSeen(entry);
+        verify(mAlarmManager, times(1)).setExactAndAllowWhileIdle(anyInt(), anyLong(), any());
+    }
+
+    @Test
+    public void testNoSnoozingOnSeenUserLocked() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        channel.lockFields(NotificationChannel.USER_LOCKED_IMPORTANCE);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        when(mCategorizer.getCategory(entry)).thenReturn(NotificationCategorizer.CATEGORY_PEOPLE);
+
+        mAgingHelper.onNotificationSeen(entry);
+        verify(mAlarmManager, never()).setExactAndAllowWhileIdle(anyInt(), anyLong(), any());
+    }
+
+    @Test
+    public void testNoSnoozingOnSeenAlreadyLow() {
+        NotificationEntry entry = mock(NotificationEntry.class);
+        when(entry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_HIGH));
+        when(entry.getImportance()).thenReturn(IMPORTANCE_MIN);
+
+        mAgingHelper.onNotificationSeen(entry);
+        verify(mAlarmManager, never()).setExactAndAllowWhileIdle(anyInt(), anyLong(), any());
+    }
+}
diff --git a/tests/src/android/ext/services/notification/AssistantSettingsTest.java b/tests/src/android/ext/services/notification/AssistantSettingsTest.java
new file mode 100644
index 0000000..5c877de
--- /dev/null
+++ b/tests/src/android/ext/services/notification/AssistantSettingsTest.java
@@ -0,0 +1,263 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.ext.services.notification.AssistantSettings.DEFAULT_MAX_SUGGESTIONS;
+import static android.provider.DeviceConfig.setProperty;
+
+import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.content.ContentResolver;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.DeviceConfig;
+import android.provider.Settings;
+import android.support.test.uiautomator.UiDevice;
+import android.testing.TestableContext;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+
+@RunWith(AndroidJUnit4.class)
+public class AssistantSettingsTest {
+    private static final String CLEAR_DEVICE_CONFIG_KEY_CMD =
+            "device_config delete " + DeviceConfig.NAMESPACE_SYSTEMUI;
+
+    private static final int USER_ID = 5;
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getContext(), null);
+
+    @Mock Runnable mOnUpdateRunnable;
+
+    private ContentResolver mResolver;
+    private AssistantSettings mAssistantSettings;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mResolver = mContext.getContentResolver();
+        Handler handler = new Handler(Looper.getMainLooper());
+
+        // To bypass real calls to global settings values, set the Settings values here.
+        Settings.Global.putFloat(mResolver,
+                Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, 0.8f);
+        Settings.Global.putInt(mResolver, Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, 2);
+        Settings.Secure.putInt(mResolver, Settings.Secure.NOTIFICATION_NEW_INTERRUPTION_MODEL, 1);
+
+        mAssistantSettings = AssistantSettings.createForTesting(
+                handler, mResolver, USER_ID, mOnUpdateRunnable);
+    }
+
+    @After
+    public void tearDown() throws IOException {
+        clearDeviceConfig();
+    }
+
+    @Test
+    public void testGenerateRepliesDisabled() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES,
+                "false",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertFalse(mAssistantSettings.mGenerateReplies);
+    }
+
+    @Test
+    public void testGenerateRepliesEnabled() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES,
+                "true",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertTrue(mAssistantSettings.mGenerateReplies);
+    }
+
+    @Test
+    public void testGenerateRepliesNullFlag() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES,
+                "false",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertFalse(mAssistantSettings.mGenerateReplies);
+
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES,
+                null,
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        // Go back to the default value.
+        assertTrue(mAssistantSettings.mGenerateReplies);
+    }
+
+    @Test
+    public void testGenerateActionsDisabled() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS,
+                "false",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertFalse(mAssistantSettings.mGenerateActions);
+    }
+
+    @Test
+    public void testGenerateActionsEnabled() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS,
+                "true",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertTrue(mAssistantSettings.mGenerateActions);
+    }
+
+    @Test
+    public void testGenerateActionsNullFlag() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS,
+                "false",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertFalse(mAssistantSettings.mGenerateActions);
+
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS,
+                null,
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        // Go back to the default value.
+        assertTrue(mAssistantSettings.mGenerateActions);
+    }
+
+    @Test
+    public void testMaxMessagesToExtract() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_MAX_MESSAGES_TO_EXTRACT,
+                "10",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertEquals(10, mAssistantSettings.mMaxMessagesToExtract);
+    }
+
+    @Test
+    public void testMaxSuggestions() {
+        runWithShellPermissionIdentity(() -> setProperty(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                SystemUiDeviceConfigFlags.NAS_MAX_SUGGESTIONS,
+                "5",
+                false /* makeDefault */));
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertEquals(5, mAssistantSettings.mMaxSuggestions);
+    }
+
+    @Test
+    public void testMaxSuggestionsEmpty() {
+        mAssistantSettings.onDeviceConfigPropertiesChanged(DeviceConfig.NAMESPACE_SYSTEMUI);
+
+        assertEquals(DEFAULT_MAX_SUGGESTIONS, mAssistantSettings.mMaxSuggestions);
+    }
+
+    @Test
+    public void testStreakLimit() {
+        verify(mOnUpdateRunnable, never()).run();
+
+        // Update settings value.
+        int newStreakLimit = 4;
+        Settings.Global.putInt(mResolver,
+                Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, newStreakLimit);
+
+        // Notify for the settings value we updated.
+        mAssistantSettings.onChange(false, Settings.Global.getUriFor(
+                Settings.Global.BLOCKING_HELPER_STREAK_LIMIT));
+
+        assertEquals(newStreakLimit, mAssistantSettings.mStreakLimit);
+        verify(mOnUpdateRunnable).run();
+    }
+
+    @Test
+    public void testDismissToViewRatioLimit() {
+        verify(mOnUpdateRunnable, never()).run();
+
+        // Update settings value.
+        float newDismissToViewRatioLimit = 3f;
+        Settings.Global.putFloat(mResolver,
+                Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT,
+                newDismissToViewRatioLimit);
+
+        // Notify for the settings value we updated.
+        mAssistantSettings.onChange(false, Settings.Global.getUriFor(
+                Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT));
+
+        assertEquals(newDismissToViewRatioLimit, mAssistantSettings.mDismissToViewRatioLimit, 1e-6);
+        verify(mOnUpdateRunnable).run();
+    }
+
+    private static void clearDeviceConfig() throws IOException {
+        UiDevice uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+        uiDevice.executeShellCommand(
+                CLEAR_DEVICE_CONFIG_KEY_CMD + " " + SystemUiDeviceConfigFlags.NAS_GENERATE_ACTIONS);
+        uiDevice.executeShellCommand(
+                CLEAR_DEVICE_CONFIG_KEY_CMD + " " + SystemUiDeviceConfigFlags.NAS_GENERATE_REPLIES);
+        uiDevice.executeShellCommand(
+                CLEAR_DEVICE_CONFIG_KEY_CMD + " "
+                + SystemUiDeviceConfigFlags.NAS_MAX_MESSAGES_TO_EXTRACT);
+        uiDevice.executeShellCommand(
+                CLEAR_DEVICE_CONFIG_KEY_CMD + " " + SystemUiDeviceConfigFlags.NAS_MAX_SUGGESTIONS);
+    }
+
+}
diff --git a/tests/src/android/ext/services/notification/AssistantTest.java b/tests/src/android/ext/services/notification/AssistantTest.java
index 6ef25e5..012dcc0 100644
--- a/tests/src/android/ext/services/notification/AssistantTest.java
+++ b/tests/src/android/ext/services/notification/AssistantTest.java
@@ -20,9 +20,9 @@
 import static android.app.NotificationManager.IMPORTANCE_LOW;
 import static android.app.NotificationManager.IMPORTANCE_MIN;
 
-import static junit.framework.Assert.assertEquals;
-
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -33,10 +33,11 @@
 import android.app.INotificationManager;
 import android.app.Notification;
 import android.app.NotificationChannel;
-import android.content.ContentResolver;
 import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.IPackageManager;
+import android.os.Build;
 import android.os.UserHandle;
-import android.provider.Settings;
 import android.service.notification.Adjustment;
 import android.service.notification.NotificationListenerService;
 import android.service.notification.NotificationListenerService.Ranking;
@@ -64,6 +65,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.FileOutputStream;
+import java.util.ArrayList;
 
 public class AssistantTest extends ServiceTestCase<Assistant> {
 
@@ -83,6 +85,8 @@
 
     @Mock INotificationManager mNoMan;
     @Mock AtomicFile mFile;
+    @Mock IPackageManager mPackageManager;
+    @Mock SmsHelper mSmsHelper;
 
     Assistant mAssistant;
     Application mApplication;
@@ -103,19 +107,30 @@
                 new Intent("android.service.notification.NotificationAssistantService");
         startIntent.setPackage("android.ext.services");
 
-        // To bypass real calls to global settings values, set the Settings values here.
-        Settings.Global.putFloat(mContext.getContentResolver(),
-                Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, 0.8f);
-        Settings.Global.putInt(mContext.getContentResolver(),
-                Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, 2);
         mApplication = (Application) InstrumentationRegistry.getInstrumentation().
                 getTargetContext().getApplicationContext();
         // Force the test to use the correct application instead of trying to use a mock application
         setApplication(mApplication);
-        bindService(startIntent);
+
+        setupService();
         mAssistant = getService();
+
+        // Override the AssistantSettings factory.
+        mAssistant.mSettingsFactory = AssistantSettings::createForTesting;
+
+        bindService(startIntent);
+
+        mAssistant.mSettings.mDismissToViewRatioLimit = 0.8f;
+        mAssistant.mSettings.mStreakLimit = 2;
+        mAssistant.mSettings.mNewInterruptionModel = true;
         mAssistant.setNoMan(mNoMan);
         mAssistant.setFile(mFile);
+        mAssistant.setPackageManager(mPackageManager);
+
+        ApplicationInfo info = mock(ApplicationInfo.class);
+        when(mPackageManager.getApplicationInfo(anyString(), anyInt(), anyInt()))
+                .thenReturn(info);
+        info.targetSdkVersion = Build.VERSION_CODES.P;
         when(mFile.startWrite()).thenReturn(mock(FileOutputStream.class));
     }
 
@@ -167,7 +182,7 @@
         mAssistant.setFakeRanking(generateRanking(sbn, P1C1));
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -180,7 +195,7 @@
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
         ArgumentCaptor<Adjustment> captor = ArgumentCaptor.forClass(Adjustment.class);
-        verify(mNoMan, times(1)).applyAdjustmentFromAssistant(any(), captor.capture());
+        verify(mNoMan, times(1)).applyEnqueuedAdjustmentFromAssistant(any(), captor.capture());
         assertEquals(sbn.getKey(), captor.getValue().getKey());
         assertEquals(Ranking.USER_SENTIMENT_NEGATIVE,
                 captor.getValue().getSignals().getInt(Adjustment.KEY_USER_SENTIMENT));
@@ -195,7 +210,7 @@
         mAssistant.setFakeRanking(generateRanking(sbn, P1C3));
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -215,7 +230,7 @@
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
         ArgumentCaptor<Adjustment> captor = ArgumentCaptor.forClass(Adjustment.class);
-        verify(mNoMan, times(1)).applyAdjustmentFromAssistant(any(), captor.capture());
+        verify(mNoMan, times(1)).applyEnqueuedAdjustmentFromAssistant(any(), captor.capture());
         assertEquals(sbn.getKey(), captor.getValue().getKey());
         assertEquals(Ranking.USER_SENTIMENT_NEGATIVE,
                 captor.getValue().getSignals().getInt(Adjustment.KEY_USER_SENTIMENT));
@@ -238,7 +253,7 @@
         sbn = generateSbn(PKG1, UID1, P1C1, "new one!", "group");
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -257,7 +272,7 @@
         sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -276,7 +291,7 @@
         sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -295,7 +310,7 @@
         sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -307,7 +322,7 @@
         mAssistant.setFakeRanking(generateRanking(sbn, P2C1));
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -319,7 +334,7 @@
         mAssistant.setFakeRanking(generateRanking(sbn, P1C2));
         mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
 
-        verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+        verify(mNoMan, never()).applyEnqueuedAdjustmentFromAssistant(any(), any());
     }
 
     @Test
@@ -398,6 +413,8 @@
         mAssistant.writeXml(serializer);
 
         Assistant assistant = new Assistant();
+        // onCreate is not invoked, so settings won't be initialised, unless we do it here.
+        assistant.mSettings = mAssistant.mSettings;
         assistant.readXml(new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())));
 
         assertEquals(ci1, assistant.getImpressions(key1));
@@ -407,8 +424,6 @@
 
     @Test
     public void testSettingsProviderUpdate() {
-        ContentResolver resolver = mApplication.getContentResolver();
-
         // Set up channels
         String key = mAssistant.getKey("pkg1", 1, "channel1");
         ChannelImpressions ci = new ChannelImpressions();
@@ -425,23 +440,38 @@
         assertEquals(false, ci.shouldTriggerBlock());
 
         // Update settings values.
-        float newDismissToViewRatioLimit = 0f;
-        int newStreakLimit = 0;
-        Settings.Global.putFloat(resolver,
-                Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT,
-                newDismissToViewRatioLimit);
-        Settings.Global.putInt(resolver,
-                Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, newStreakLimit);
+        mAssistant.mSettings.mDismissToViewRatioLimit = 0f;
+        mAssistant.mSettings.mStreakLimit = 0;
 
         // Notify for the settings values we updated.
-        resolver.notifyChange(
-                Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT), null);
-        resolver.notifyChange(
-                Settings.Global.getUriFor(
-                        Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT),
-                null);
+        mAssistant.mSettings.mOnUpdateRunnable.run();
 
         // With the new threshold, the blocking helper should be triggered.
         assertEquals(true, ci.shouldTriggerBlock());
     }
+
+    @Test
+    public void testTrimLiveNotifications() {
+        StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null);
+        mAssistant.setFakeRanking(generateRanking(sbn, P1C1));
+
+        mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+        assertTrue(mAssistant.mLiveNotifications.containsKey(sbn.getKey()));
+
+        mAssistant.onNotificationRemoved(
+                sbn, mock(RankingMap.class), new NotificationStats(), 0);
+
+        assertFalse(mAssistant.mLiveNotifications.containsKey(sbn.getKey()));
+    }
+
+    @Test
+    public void testAssistantNeverIncreasesImportanceWhenSuggestingSilent() throws Exception {
+        StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C3, "min notif!", null);
+        Adjustment adjust = mAssistant.createEnqueuedNotificationAdjustment(
+                new NotificationEntry(mContext, mPackageManager, sbn, P1C3, mSmsHelper),
+                new ArrayList<>(),
+                new ArrayList<>());
+        assertEquals(IMPORTANCE_MIN, adjust.getSignals().getInt(Adjustment.KEY_IMPORTANCE));
+    }
 }
diff --git a/tests/src/android/ext/services/notification/EntityTypeCounterTest.java b/tests/src/android/ext/services/notification/EntityTypeCounterTest.java
new file mode 100644
index 0000000..ada61d0
--- /dev/null
+++ b/tests/src/android/ext/services/notification/EntityTypeCounterTest.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.textclassifier.TextClassifier;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class EntityTypeCounterTest {
+    private EntityTypeCounter mCounter;
+
+    @Before
+    public void setup() {
+        mCounter = new EntityTypeCounter();
+    }
+
+    @Test
+    public void testIncrementAndGetCount() {
+        mCounter.increment(TextClassifier.TYPE_URL);
+        mCounter.increment(TextClassifier.TYPE_URL);
+        mCounter.increment(TextClassifier.TYPE_URL);
+
+        mCounter.increment(TextClassifier.TYPE_PHONE);
+        mCounter.increment(TextClassifier.TYPE_PHONE);
+
+        assertThat(mCounter.getCount(TextClassifier.TYPE_URL)).isEqualTo(3);
+        assertThat(mCounter.getCount(TextClassifier.TYPE_PHONE)).isEqualTo(2);
+        assertThat(mCounter.getCount(TextClassifier.TYPE_DATE_TIME)).isEqualTo(0);
+    }
+
+    @Test
+    public void testIncrementAndGetCount_typeDateAndDateTime() {
+        mCounter.increment(TextClassifier.TYPE_DATE_TIME);
+        mCounter.increment(TextClassifier.TYPE_DATE);
+
+        assertThat(mCounter.getCount(TextClassifier.TYPE_DATE_TIME)).isEqualTo(2);
+        assertThat(mCounter.getCount(TextClassifier.TYPE_DATE)).isEqualTo(2);
+    }
+}
diff --git a/tests/src/android/ext/services/notification/NotificationCategorizerTest.java b/tests/src/android/ext/services/notification/NotificationCategorizerTest.java
new file mode 100644
index 0000000..ea77d31
--- /dev/null
+++ b/tests/src/android/ext/services/notification/NotificationCategorizerTest.java
@@ -0,0 +1,202 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.app.NotificationManager.IMPORTANCE_LOW;
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+import static android.os.Process.FIRST_APPLICATION_UID;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.os.Process;
+import android.service.notification.StatusBarNotification;
+import android.testing.TestableContext;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+public class NotificationCategorizerTest {
+    @Mock
+    private NotificationEntry mEntry;
+    @Mock
+    private StatusBarNotification mSbn;
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getContext(), null);
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        when(mEntry.getSbn()).thenReturn(mSbn);
+        when(mSbn.getUid()).thenReturn(Process.myUid());
+        when(mSbn.getPackageName()).thenReturn(mContext.getPackageName());
+    }
+
+    @Test
+    public void testPeopleCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.involvesPeople()).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_PEOPLE, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_PEOPLE));
+    }
+
+    @Test
+    public void testMin() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_MIN));
+        when(mEntry.involvesPeople()).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_MIN, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_MIN));
+    }
+
+    @Test
+    public void testHigh() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_HIGH));
+
+        assertEquals(NotificationCategorizer.CATEGORY_HIGH, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_HIGH));
+    }
+
+    @Test
+    public void testOngoingCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.isOngoing()).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_ONGOING, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_ONGOING));
+
+        when(mEntry.isOngoing()).thenReturn(false);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE));
+    }
+
+    @Test
+    public void testAlarmCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.isCategory(Notification.CATEGORY_ALARM)).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_ALARM, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_ALARM));
+
+        when(mEntry.isCategory(Notification.CATEGORY_ALARM)).thenReturn(false);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE));
+    }
+
+    @Test
+    public void testCallCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.isCategory(Notification.CATEGORY_CALL)).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_CALL, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_CALL));
+
+        when(mEntry.isCategory(Notification.CATEGORY_CALL)).thenReturn(false);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE));
+    }
+
+    @Test
+    public void testReminderCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.isCategory(Notification.CATEGORY_REMINDER)).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_REMINDER, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_REMINDER));
+
+        when(mEntry.isCategory(Notification.CATEGORY_REMINDER)).thenReturn(false);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE));
+    }
+
+    @Test
+    public void testEventCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.isCategory(Notification.CATEGORY_EVENT)).thenReturn(true);
+
+        assertEquals(NotificationCategorizer.CATEGORY_EVENT, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_EVENT));
+
+        when(mEntry.isCategory(Notification.CATEGORY_EVENT)).thenReturn(false);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+    }
+
+    @Test
+    public void testSystemCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_DEFAULT));
+        when(mEntry.getImportance()).thenReturn(IMPORTANCE_DEFAULT);
+        when(mSbn.getUid()).thenReturn(FIRST_APPLICATION_UID - 1);
+
+        assertEquals(NotificationCategorizer.CATEGORY_SYSTEM, nc.getCategory(mEntry));
+        assertFalse(nc.shouldSilence(NotificationCategorizer.CATEGORY_SYSTEM));
+
+        when(mSbn.getUid()).thenReturn(FIRST_APPLICATION_UID);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+    }
+
+    @Test
+    public void testSystemLowCategory() {
+        NotificationCategorizer nc = new NotificationCategorizer();
+
+        when(mEntry.getChannel()).thenReturn(new NotificationChannel("", "", IMPORTANCE_LOW));
+        when(mEntry.getImportance()).thenReturn(IMPORTANCE_LOW);
+        when(mSbn.getUid()).thenReturn(FIRST_APPLICATION_UID - 1);
+
+        assertEquals(NotificationCategorizer.CATEGORY_SYSTEM_LOW, nc.getCategory(mEntry));
+        assertTrue(nc.shouldSilence(NotificationCategorizer.CATEGORY_SYSTEM_LOW));
+
+        when(mSbn.getUid()).thenReturn(FIRST_APPLICATION_UID);
+        assertEquals(NotificationCategorizer.CATEGORY_EVERYTHING_ELSE, nc.getCategory(mEntry));
+    }
+}
diff --git a/tests/src/android/ext/services/notification/NotificationEntryTest.java b/tests/src/android/ext/services/notification/NotificationEntryTest.java
new file mode 100644
index 0000000..c026079
--- /dev/null
+++ b/tests/src/android/ext/services/notification/NotificationEntryTest.java
@@ -0,0 +1,266 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static android.app.Notification.FLAG_CAN_COLORIZE;
+import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.media.AudioAttributes.USAGE_ALARM;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.Person;
+import android.content.ComponentName;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.IPackageManager;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Icon;
+import android.media.AudioAttributes;
+import android.os.Build;
+import android.os.Process;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.testing.TestableContext;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+@RunWith(AndroidJUnit4.class)
+public class NotificationEntryTest {
+    private String mPkg = "pkg";
+    private int mUid = 2018;
+    @Mock
+    private IPackageManager mPackageManager;
+    @Mock
+    private ApplicationInfo mAppInfo;
+    @Mock
+    private SmsHelper mSmsHelper;
+
+    private static final String DEFAULT_SMS_PACKAGE_NAME = "foo";
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getContext(), null);
+
+    private StatusBarNotification generateSbn(String channelId) {
+        Notification n = new Notification.Builder(mContext, channelId)
+                .setContentTitle("foo")
+                .build();
+
+        return new StatusBarNotification(mPkg, mPkg, 0, "tag", mUid, mUid, n,
+                UserHandle.SYSTEM, null, 0);
+    }
+
+    private StatusBarNotification generateSbn(String channelId, String packageName) {
+        Notification n = new Notification.Builder(mContext, channelId)
+                .setContentTitle("foo")
+                .build();
+
+        return new StatusBarNotification(packageName, packageName, 0, "tag", mUid, mUid, n,
+                UserHandle.SYSTEM, null, 0);
+    }
+
+    private StatusBarNotification generateSbn(Notification n) {
+        return new StatusBarNotification(mPkg, mPkg, 0, "tag", mUid, mUid, n,
+                UserHandle.SYSTEM, null, 0);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mPkg = mContext.getPackageName();
+        mUid = Process.myUid();
+        when(mPackageManager.getApplicationInfo(anyString(), anyInt(), anyInt()))
+                .thenReturn(mAppInfo);
+        mAppInfo.targetSdkVersion = Build.VERSION_CODES.P;
+        when(mSmsHelper.getDefaultSmsApplication())
+                .thenReturn(new ComponentName(DEFAULT_SMS_PACKAGE_NAME, "bar"));
+    }
+
+    @Test
+    public void testHasPerson() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        ArrayList<Person> people = new ArrayList<>();
+        people.add(new Person.Builder().setKey("mailto:testing@android.com").build());
+        sbn.getNotification().extras.putParcelableArrayList(Notification.EXTRA_PEOPLE_LIST, people);
+
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        assertTrue(entry.involvesPeople());
+    }
+
+    @Test
+    public void testNotPerson() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId());
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        assertFalse(entry.involvesPeople());
+    }
+
+    @Test
+    public void testHasPerson_matchesDefaultSmsApp() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId(), DEFAULT_SMS_PACKAGE_NAME);
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        assertTrue(entry.involvesPeople());
+    }
+
+    @Test
+    public void testHasPerson_doesntMatchDefaultSmsApp() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        StatusBarNotification sbn = generateSbn(channel.getId(), "abc");
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, sbn, channel, mSmsHelper);
+        assertFalse(entry.involvesPeople());
+    }
+
+    @Test
+    public void testIsInboxStyle() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setStyle(new Notification.InboxStyle())
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+        assertTrue(entry.hasStyle(Notification.InboxStyle.class));
+    }
+
+    @Test
+    public void testIsMessagingStyle() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setStyle(new Notification.MessagingStyle(""))
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+        assertTrue(entry.hasStyle(Notification.MessagingStyle.class));
+    }
+
+    @Test
+    public void testIsNotPersonStyle() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setStyle(new Notification.BigPictureStyle())
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+        assertFalse(entry.hasStyle(Notification.InboxStyle.class));
+        assertFalse(entry.hasStyle(Notification.MessagingStyle.class));
+    }
+
+    @Test
+    public void testIsAudioAttributes() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        channel.setSound(null, new AudioAttributes.Builder().setUsage(USAGE_ALARM).build());
+
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(channel.getId()), channel, mSmsHelper);
+
+        assertTrue(entry.isAudioAttributesUsage(USAGE_ALARM));
+    }
+
+    @Test
+    public void testIsNotAudioAttributes() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(channel.getId()), channel, mSmsHelper);
+
+        assertFalse(entry.isAudioAttributesUsage(USAGE_ALARM));
+    }
+
+    @Test
+    public void testIsCategory() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setCategory(Notification.CATEGORY_EMAIL)
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+
+        assertTrue(entry.isCategory(Notification.CATEGORY_EMAIL));
+        assertFalse(entry.isCategory(Notification.CATEGORY_MESSAGE));
+    }
+
+    @Test
+    public void testIsOngoing() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setFlag(FLAG_FOREGROUND_SERVICE, true)
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+
+        assertTrue(entry.isOngoing());
+    }
+
+    @Test
+    public void testIsNotOngoing() {
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        Notification n = new Notification.Builder(mContext, channel.getId())
+                .setFlag(FLAG_CAN_COLORIZE, true)
+                .build();
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+
+        assertFalse(entry.isOngoing());
+    }
+
+    @Test
+    public void testShrinkNotification() {
+        Notification n = new Notification.Builder(mContext, "")
+                .setLargeIcon(Icon.createWithResource(
+                        mContext, android.R.drawable.alert_dark_frame))
+                .setSmallIcon(android.R.drawable.sym_def_app_icon)
+                .build();
+        n.largeIcon = Bitmap.createBitmap(100, 200, Bitmap.Config.RGB_565);
+        NotificationChannel channel = new NotificationChannel("", "", IMPORTANCE_HIGH);
+
+        NotificationEntry entry = new NotificationEntry(
+                mContext, mPackageManager, generateSbn(n), channel, mSmsHelper);
+
+        assertNull(entry.getNotification().getSmallIcon());
+        assertNull(entry.getNotification().getLargeIcon());
+        assertNull(entry.getNotification().largeIcon);
+        assertNull(entry.getNotification().extras.getParcelable(Notification.EXTRA_LARGE_ICON));
+    }
+}
diff --git a/tests/src/android/ext/services/notification/SmartActionsHelperTest.java b/tests/src/android/ext/services/notification/SmartActionsHelperTest.java
new file mode 100644
index 0000000..52b7225
--- /dev/null
+++ b/tests/src/android/ext/services/notification/SmartActionsHelperTest.java
@@ -0,0 +1,566 @@
+/**
+ * Copyright (C) 2018 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.ext.services.notification;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.annotation.NonNull;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Person;
+import android.app.RemoteInput;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.IPackageManager;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.os.Process;
+import android.service.notification.NotificationAssistantService;
+import android.service.notification.StatusBarNotification;
+import android.view.textclassifier.ConversationAction;
+import android.view.textclassifier.ConversationActions;
+import android.view.textclassifier.TextClassificationManager;
+import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextClassifierEvent;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import javax.annotation.Nullable;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+@RunWith(AndroidJUnit4.class)
+public class SmartActionsHelperTest {
+    private static final String RESULT_ID = "id";
+    private static final float SCORE = 0.7f;
+    private static final CharSequence SMART_REPLY = "Home";
+    private static final ConversationAction REPLY_ACTION =
+            new ConversationAction.Builder(ConversationAction.TYPE_TEXT_REPLY)
+                    .setTextReply(SMART_REPLY)
+                    .setConfidenceScore(SCORE)
+                    .build();
+    private static final String MESSAGE = "Where are you?";
+
+    @Mock
+    IPackageManager mIPackageManager;
+    @Mock
+    private TextClassifier mTextClassifier;
+    private StatusBarNotification mStatusBarNotification;
+    @Mock
+    private SmsHelper mSmsHelper;
+
+    private SmartActionsHelper mSmartActionsHelper;
+    private Context mContext;
+    private Notification.Builder mNotificationBuilder;
+    private AssistantSettings mSettings;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        mContext = InstrumentationRegistry.getTargetContext();
+
+        mContext.getSystemService(TextClassificationManager.class)
+                .setTextClassifier(mTextClassifier);
+        when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class)))
+                .thenReturn(new ConversationActions(Arrays.asList(REPLY_ACTION), RESULT_ID));
+
+        mNotificationBuilder = new Notification.Builder(mContext, "channel");
+        mSettings = AssistantSettings.createForTesting(
+                null, null, Process.myUserHandle().getIdentifier(), null);
+        mSettings.mGenerateActions = true;
+        mSettings.mGenerateReplies = true;
+        mSmartActionsHelper = new SmartActionsHelper(mContext, mSettings);
+    }
+
+    private void setStatusBarNotification(Notification n) {
+        mStatusBarNotification = new StatusBarNotification("random.app", "random.app", 0,
+        "tag", Process.myUid(), Process.myPid(), n, Process.myUserHandle(), null, 0);
+    }
+
+    @Test
+    public void testSuggest_notMessageNotification() {
+        Notification notification = mNotificationBuilder.setContentText(MESSAGE).build();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+
+        verify(mTextClassifier, never())
+                .suggestConversationActions(any(ConversationActions.Request.class));
+    }
+
+    @Test
+    public void testSuggest_noInlineReply() {
+        Notification notification =
+                mNotificationBuilder
+                        .setContentText(MESSAGE)
+                        .setCategory(Notification.CATEGORY_MESSAGE)
+                        .build();
+        setStatusBarNotification(notification);
+
+        ConversationActions.Request request = runSuggestAndCaptureRequest();
+
+        // actions are enabled, but replies are not.
+        assertThat(
+                request.getTypeConfig().resolveEntityListModifications(
+                        Arrays.asList(ConversationAction.TYPE_TEXT_REPLY,
+                                ConversationAction.TYPE_OPEN_URL)))
+                .containsExactly(ConversationAction.TYPE_OPEN_URL);
+    }
+
+    @Test
+    public void testSuggest_settingsOff() {
+        mSettings.mGenerateActions = false;
+        mSettings.mGenerateReplies = false;
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+
+        verify(mTextClassifier, never())
+                .suggestConversationActions(any(ConversationActions.Request.class));
+    }
+
+    @Test
+    public void testSuggest_settings_repliesOnActionsOff() {
+        mSettings.mGenerateReplies = true;
+        mSettings.mGenerateActions = false;
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        ConversationActions.Request request = runSuggestAndCaptureRequest();
+
+        // replies are enabled, but actions are not.
+        assertThat(
+                request.getTypeConfig().resolveEntityListModifications(
+                        Arrays.asList(ConversationAction.TYPE_TEXT_REPLY,
+                                ConversationAction.TYPE_OPEN_URL)))
+                .containsExactly(ConversationAction.TYPE_TEXT_REPLY);
+    }
+
+    @Test
+    public void testSuggest_settings_repliesOffActionsOn() {
+        mSettings.mGenerateReplies = false;
+        mSettings.mGenerateActions = true;
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        ConversationActions.Request request = runSuggestAndCaptureRequest();
+
+        // actions are enabled, but replies are not.
+        assertThat(
+                request.getTypeConfig().resolveEntityListModifications(
+                        Arrays.asList(ConversationAction.TYPE_TEXT_REPLY,
+                                ConversationAction.TYPE_OPEN_URL)))
+                .containsExactly(ConversationAction.TYPE_OPEN_URL);
+    }
+
+
+    @Test
+    public void testSuggest_nonMessageStyleMessageNotification() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        List<ConversationActions.Message> messages =
+                runSuggestAndCaptureRequest().getConversation();
+
+        assertThat(messages).hasSize(1);
+        MessageSubject.assertThat(messages.get(0)).hasText(MESSAGE);
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture());
+        TextClassifierEvent textClassifierEvent = argumentCaptor.getValue();
+        assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertThat(textClassifierEvent.getEntityTypes()).asList()
+                .containsExactly(ConversationAction.TYPE_TEXT_REPLY);
+    }
+
+    @Test
+    public void testSuggest_messageStyle() {
+        Person me = new Person.Builder().setName("Me").build();
+        Person userA = new Person.Builder().setName("A").build();
+        Person userB = new Person.Builder().setName("B").build();
+        Notification.MessagingStyle style =
+                new Notification.MessagingStyle(me)
+                        .addMessage("firstMessage", 1000, (Person) null)
+                        .addMessage("secondMessage", 2000, me)
+                        .addMessage("thirdMessage", 3000, userA)
+                        .addMessage("fourthMessage", 4000, userB);
+        Notification notification =
+                mNotificationBuilder
+                        .setContentText("You have three new messages")
+                        .setStyle(style)
+                        .setActions(createReplyAction())
+                        .build();
+        setStatusBarNotification(notification);
+
+        List<ConversationActions.Message> messages =
+                runSuggestAndCaptureRequest().getConversation();
+        assertThat(messages).hasSize(4);
+
+        ConversationActions.Message firstMessage = messages.get(0);
+        MessageSubject.assertThat(firstMessage).hasText("firstMessage");
+        MessageSubject.assertThat(firstMessage)
+                .hasPerson(ConversationActions.Message.PERSON_USER_SELF);
+        MessageSubject.assertThat(firstMessage)
+                .hasReferenceTime(createZonedDateTimeFromMsUtc(1000));
+
+        ConversationActions.Message secondMessage = messages.get(1);
+        MessageSubject.assertThat(secondMessage).hasText("secondMessage");
+        MessageSubject.assertThat(secondMessage)
+                .hasPerson(ConversationActions.Message.PERSON_USER_SELF);
+        MessageSubject.assertThat(secondMessage)
+                .hasReferenceTime(createZonedDateTimeFromMsUtc(2000));
+
+        ConversationActions.Message thirdMessage = messages.get(2);
+        MessageSubject.assertThat(thirdMessage).hasText("thirdMessage");
+        MessageSubject.assertThat(thirdMessage).hasPerson(userA);
+        MessageSubject.assertThat(thirdMessage)
+                .hasReferenceTime(createZonedDateTimeFromMsUtc(3000));
+
+        ConversationActions.Message fourthMessage = messages.get(3);
+        MessageSubject.assertThat(fourthMessage).hasText("fourthMessage");
+        MessageSubject.assertThat(fourthMessage).hasPerson(userB);
+        MessageSubject.assertThat(fourthMessage)
+                .hasReferenceTime(createZonedDateTimeFromMsUtc(4000));
+
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture());
+        TextClassifierEvent textClassifierEvent = argumentCaptor.getValue();
+        assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertThat(textClassifierEvent.getEntityTypes()).asList()
+                .containsExactly(ConversationAction.TYPE_TEXT_REPLY);
+    }
+
+    @Test
+    public void testSuggest_lastMessageLocalUser() {
+        Person me = new Person.Builder().setName("Me").build();
+        Person userA = new Person.Builder().setName("A").build();
+        Notification.MessagingStyle style =
+                new Notification.MessagingStyle(me)
+                        .addMessage("firstMessage", 1000, userA)
+                        .addMessage("secondMessage", 2000, me);
+        Notification notification =
+                mNotificationBuilder
+                        .setContentText("You have two new messages")
+                        .setStyle(style)
+                        .setActions(createReplyAction())
+                        .build();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+
+        verify(mTextClassifier, never())
+                .suggestConversationActions(any(ConversationActions.Request.class));
+    }
+
+    @Test
+    public void testSuggest_messageStyle_noPerson() {
+        Person me = new Person.Builder().setName("Me").build();
+        Notification.MessagingStyle style =
+                new Notification.MessagingStyle(me).addMessage("message", 1000, (Person) null);
+        Notification notification =
+                mNotificationBuilder
+                        .setContentText("You have one new message")
+                        .setStyle(style)
+                        .setActions(createReplyAction())
+                        .build();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+
+        verify(mTextClassifier, never())
+                .suggestConversationActions(any(ConversationActions.Request.class));
+    }
+
+    @Test
+    public void testOnSuggestedReplySent() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onSuggestedReplySent(mStatusBarNotification.getKey(), SMART_REPLY,
+                NotificationAssistantService.SOURCE_FROM_ASSISTANT);
+
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier, times(2)).onTextClassifierEvent(argumentCaptor.capture());
+        List<TextClassifierEvent> events = argumentCaptor.getAllValues();
+        assertTextClassifierEvent(events.get(0), TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertTextClassifierEvent(events.get(1), TextClassifierEvent.TYPE_SMART_ACTION);
+        float[] scores = events.get(1).getScores();
+        assertThat(scores).hasLength(1);
+        assertThat(scores[0]).isEqualTo(SCORE);
+    }
+
+    @Test
+    public void testOnSuggestedReplySent_anotherNotification() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onSuggestedReplySent(
+                "something_else", MESSAGE, NotificationAssistantService.SOURCE_FROM_ASSISTANT);
+
+        verify(mTextClassifier, never()).onTextClassifierEvent(
+                argThat(new TextClassifierEventMatcher(TextClassifierEvent.TYPE_SMART_ACTION)));
+    }
+
+    @Test
+    public void testOnSuggestedReplySent_missingResultId() {
+        when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class)))
+                .thenReturn(new ConversationActions(Collections.singletonList(REPLY_ACTION), null));
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onSuggestedReplySent(mStatusBarNotification.getKey(), SMART_REPLY,
+                NotificationAssistantService.SOURCE_FROM_ASSISTANT);
+
+        verify(mTextClassifier, never()).onTextClassifierEvent(any(TextClassifierEvent.class));
+    }
+
+    @Test
+    public void testOnNotificationDirectReply() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onNotificationDirectReplied(mStatusBarNotification.getKey());
+
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier, times(2)).onTextClassifierEvent(argumentCaptor.capture());
+        List<TextClassifierEvent> events = argumentCaptor.getAllValues();
+        assertTextClassifierEvent(events.get(0), TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertTextClassifierEvent(events.get(1), TextClassifierEvent.TYPE_MANUAL_REPLY);
+    }
+
+    @Test
+    public void testOnNotificationExpansionChanged() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onNotificationExpansionChanged(createNotificationEntry(), true);
+
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier, times(2)).onTextClassifierEvent(argumentCaptor.capture());
+        List<TextClassifierEvent> events = argumentCaptor.getAllValues();
+        assertTextClassifierEvent(events.get(0), TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertTextClassifierEvent(events.get(1), TextClassifierEvent.TYPE_ACTIONS_SHOWN);
+    }
+
+    @Test
+    public void testOnNotificationsSeen_notExpanded() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onNotificationExpansionChanged(createNotificationEntry(), false);
+
+        verify(mTextClassifier, never()).onTextClassifierEvent(
+                argThat(new TextClassifierEventMatcher(TextClassifierEvent.TYPE_ACTIONS_SHOWN)));
+    }
+
+    @Test
+    public void testOnNotifications_expanded() {
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+
+        mSmartActionsHelper.suggest(createNotificationEntry());
+        mSmartActionsHelper.onNotificationExpansionChanged(createNotificationEntry(), true);
+
+        ArgumentCaptor<TextClassifierEvent> argumentCaptor =
+                ArgumentCaptor.forClass(TextClassifierEvent.class);
+        verify(mTextClassifier, times(2)).onTextClassifierEvent(argumentCaptor.capture());
+        List<TextClassifierEvent> events = argumentCaptor.getAllValues();
+        assertTextClassifierEvent(events.get(0), TextClassifierEvent.TYPE_ACTIONS_GENERATED);
+        assertTextClassifierEvent(events.get(1), TextClassifierEvent.TYPE_ACTIONS_SHOWN);
+    }
+
+    @Test
+    public void testCopyAction() {
+        Bundle extras = new Bundle();
+        Bundle entitiesExtras = new Bundle();
+        entitiesExtras.putString(SmartActionsHelper.KEY_TEXT, "12345");
+        extras.putParcelable(SmartActionsHelper.ENTITIES_EXTRAS, entitiesExtras);
+        ConversationAction conversationAction =
+                new ConversationAction.Builder(ConversationAction.TYPE_COPY)
+                        .setExtras(extras)
+                        .build();
+        when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class)))
+                .thenReturn(
+                        new ConversationActions(
+                                Collections.singletonList(conversationAction), null));
+
+        Notification notification = createMessageNotification();
+        setStatusBarNotification(notification);
+        SmartActionsHelper.SmartSuggestions suggestions =
+                mSmartActionsHelper.suggest(createNotificationEntry());
+
+        assertThat(suggestions.actions).hasSize(1);
+        Notification.Action action = suggestions.actions.get(0);
+        assertThat(action.title).isEqualTo("12345");
+    }
+
+    private ZonedDateTime createZonedDateTimeFromMsUtc(long msUtc) {
+        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(msUtc), ZoneOffset.systemDefault());
+    }
+
+    private ConversationActions.Request runSuggestAndCaptureRequest() {
+        mSmartActionsHelper.suggest(createNotificationEntry());
+
+        ArgumentCaptor<ConversationActions.Request> argumentCaptor =
+                ArgumentCaptor.forClass(ConversationActions.Request.class);
+        verify(mTextClassifier).suggestConversationActions(argumentCaptor.capture());
+        return argumentCaptor.getValue();
+    }
+
+    private Notification.Action createReplyAction() {
+        PendingIntent pendingIntent =
+                PendingIntent.getActivity(mContext, 0, new Intent(mContext, this.getClass()), 0);
+        RemoteInput remoteInput = new RemoteInput.Builder("result")
+                .setAllowFreeFormInput(true)
+                .build();
+        return new Notification.Action.Builder(
+                Icon.createWithResource(mContext.getResources(),
+                        android.R.drawable.stat_sys_warning),
+                "Reply", pendingIntent)
+                .addRemoteInput(remoteInput)
+                .build();
+    }
+
+    private NotificationEntry createNotificationEntry() {
+        NotificationChannel channel =
+                new NotificationChannel("id", "name", NotificationManager.IMPORTANCE_DEFAULT);
+        return new NotificationEntry(
+                mContext, mIPackageManager, mStatusBarNotification, channel, mSmsHelper);
+    }
+
+    private Notification createMessageNotification() {
+        return mNotificationBuilder
+                .setContentText(MESSAGE)
+                .setCategory(Notification.CATEGORY_MESSAGE)
+                .setActions(createReplyAction())
+                .build();
+    }
+
+    private void assertTextClassifierEvent(
+            TextClassifierEvent textClassifierEvent, int expectedEventType) {
+        assertThat(textClassifierEvent.getEventCategory())
+                .isEqualTo(TextClassifierEvent.CATEGORY_CONVERSATION_ACTIONS);
+        assertThat(textClassifierEvent.getEventContext().getPackageName())
+                .isEqualTo(InstrumentationRegistry.getTargetContext().getPackageName());
+        assertThat(textClassifierEvent.getEventContext().getWidgetType())
+                .isEqualTo(TextClassifier.WIDGET_TYPE_NOTIFICATION);
+        assertThat(textClassifierEvent.getEventType()).isEqualTo(expectedEventType);
+    }
+
+    private static final class MessageSubject
+            extends Subject<MessageSubject, ConversationActions.Message> {
+
+        private static final Subject.Factory<MessageSubject, ConversationActions.Message> FACTORY =
+                new Subject.Factory<MessageSubject, ConversationActions.Message>() {
+                    @Override
+                    public MessageSubject createSubject(
+                            @NonNull FailureMetadata failureMetadata,
+                            @NonNull ConversationActions.Message subject) {
+                        return new MessageSubject(failureMetadata, subject);
+                    }
+                };
+
+        private MessageSubject(
+                FailureMetadata failureMetadata, @Nullable ConversationActions.Message subject) {
+            super(failureMetadata, subject);
+        }
+
+        private void hasText(String text) {
+            if (!Objects.equals(text, getSubject().getText().toString())) {
+                failWithBadResults("has text", text, "has", getSubject().getText());
+            }
+        }
+
+        private void hasPerson(Person person) {
+            if (!Objects.equals(person, getSubject().getAuthor())) {
+                failWithBadResults("has author", person, "has", getSubject().getAuthor());
+            }
+        }
+
+        private void hasReferenceTime(ZonedDateTime referenceTime) {
+            if (!Objects.equals(referenceTime, getSubject().getReferenceTime())) {
+                failWithBadResults(
+                        "has reference time",
+                        referenceTime,
+                        "has",
+                        getSubject().getReferenceTime());
+            }
+        }
+
+        private static MessageSubject assertThat(ConversationActions.Message message) {
+            return assertAbout(FACTORY).that(message);
+        }
+    }
+
+    private final class TextClassifierEventMatcher implements ArgumentMatcher<TextClassifierEvent> {
+
+        private int mType;
+
+        private TextClassifierEventMatcher(int type) {
+            mType = type;
+        }
+
+        @Override
+        public boolean matches(TextClassifierEvent textClassifierEvent) {
+            if (textClassifierEvent == null) {
+                return false;
+            }
+            return mType == textClassifierEvent.getEventType();
+        }
+    }
+}
diff --git a/tests/src/android/ext/services/sms/FinancialSmsServiceImplTest.java b/tests/src/android/ext/services/sms/FinancialSmsServiceImplTest.java
new file mode 100644
index 0000000..12575a6
--- /dev/null
+++ b/tests/src/android/ext/services/sms/FinancialSmsServiceImplTest.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2018 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.ext.services.sms;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
+
+import org.junit.Test;
+
+/**
+ * Contains the base tests for FinancialSmsServiceImpl.
+ */
+public class FinancialSmsServiceImplTest {
+
+    private final FinancialSmsServiceImpl mService = new FinancialSmsServiceImpl();
+
+    @Test
+    public void testOnGetSmsMessages_nullWithNoParamData() {
+        assertThat(mService.onGetSmsMessages(new Bundle())).isNull();
+    }
+}
diff --git a/tests/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImplTest.java b/tests/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImplTest.java
new file mode 100644
index 0000000..a9cb63e
--- /dev/null
+++ b/tests/src/android/ext/services/watchdog/ExplicitHealthCheckServiceImplTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.ext.services.watchdog;
+
+import static android.service.watchdog.ExplicitHealthCheckService.EXTRA_REQUESTED_PACKAGES;
+import static android.service.watchdog.ExplicitHealthCheckService.EXTRA_SUPPORTED_PACKAGES;
+import static android.service.watchdog.ExplicitHealthCheckService.PackageConfig;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
+
+import android.Manifest;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.os.RemoteCallback;
+import android.service.watchdog.ExplicitHealthCheckService;
+import android.service.watchdog.IExplicitHealthCheckService;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.rule.ServiceTestRule;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * Contains the base tests that does not rely on the specific algorithm implementation.
+ */
+public class ExplicitHealthCheckServiceImplTest {
+    private static final String NETWORK_STACK_CONNECTOR_CLASS =
+            "android.net.INetworkStackConnector";
+
+    private final Context mContext = InstrumentationRegistry.getContext();
+    private IExplicitHealthCheckService mService;
+    private String mNetworkStackPackageName;
+
+    @Rule
+    public ServiceTestRule mServiceTestRule;
+
+    @Before
+    public void setUp() throws Exception {
+        InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .adoptShellPermissionIdentity(
+                        Manifest.permission.BIND_EXPLICIT_HEALTH_CHECK_SERVICE);
+
+        mServiceTestRule = new ServiceTestRule();
+        mService = IExplicitHealthCheckService.Stub.asInterface(
+                mServiceTestRule.bindService(getExtServiceIntent()));
+        mNetworkStackPackageName = getNetworkStackPackage();
+        assumeFalse(mNetworkStackPackageName == null);
+    }
+
+    @After
+    public void tearDown() {
+        InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .dropShellPermissionIdentity();
+    }
+
+    @Test
+    public void testHealthCheckSupportedPackage() throws Exception {
+        List<PackageConfig> supportedPackages = new ArrayList<>();
+        CountDownLatch latch = new CountDownLatch(1);
+
+        mService.getSupportedPackages(new RemoteCallback(result -> {
+            supportedPackages.addAll(result.getParcelableArrayList(EXTRA_SUPPORTED_PACKAGES));
+            latch.countDown();
+        }));
+        latch.await();
+
+        // TODO: Support DeviceConfig changes for the health check timeout
+        assertThat(supportedPackages).hasSize(1);
+        assertThat(supportedPackages.get(0).getPackageName())
+                .isEqualTo(mNetworkStackPackageName);
+        assertThat(supportedPackages.get(0).getHealthCheckTimeoutMillis())
+                .isEqualTo(ExplicitHealthCheckServiceImpl.DEFAULT_REQUEST_TIMEOUT_MILLIS);
+    }
+
+    @Test
+    public void testHealthCheckRequests() throws Exception {
+        List<String> requestedPackages = new ArrayList<>();
+        CountDownLatch latch1 = new CountDownLatch(1);
+        CountDownLatch latch2 = new CountDownLatch(1);
+        CountDownLatch latch3 = new CountDownLatch(1);
+
+        // Initially, no health checks requested
+        mService.getRequestedPackages(new RemoteCallback(result -> {
+            requestedPackages.addAll(result.getParcelableArrayList(EXTRA_REQUESTED_PACKAGES));
+            latch1.countDown();
+        }));
+
+        // Verify that no health checks requested
+        latch1.await();
+        assertThat(requestedPackages).isEmpty();
+
+        // Then request health check
+        mService.request(mNetworkStackPackageName);
+
+        // Verify that health check is requested for network stack
+        mService.getRequestedPackages(new RemoteCallback(result -> {
+            requestedPackages.addAll(result.getParcelableArrayList(EXTRA_REQUESTED_PACKAGES));
+            latch2.countDown();
+        }));
+        latch2.await();
+        assertThat(requestedPackages).hasSize(1);
+        assertThat(requestedPackages.get(0)).isEqualTo(mNetworkStackPackageName);
+
+        // Then cancel health check
+        requestedPackages.clear();
+        mService.cancel(mNetworkStackPackageName);
+
+        // Verify that health check is cancelled for network stack
+        mService.getRequestedPackages(new RemoteCallback(result -> {
+            requestedPackages.addAll(result.getParcelableArrayList(EXTRA_REQUESTED_PACKAGES));
+            latch3.countDown();
+        }));
+        latch3.await();
+        assertThat(requestedPackages).isEmpty();
+    }
+
+    private String getNetworkStackPackage() {
+        Intent intent = new Intent(NETWORK_STACK_CONNECTOR_CLASS);
+        ComponentName comp = intent.resolveSystemService(mContext.getPackageManager(), 0);
+        if (comp != null) {
+            return comp.getPackageName();
+        } else {
+            // On Go devices, or any device that does not ship the network stack module.
+            // The network stack will live in system_server process, so no need to monitor.
+            return null;
+        }
+    }
+
+    private Intent getExtServiceIntent() {
+        ComponentName component = getExtServiceComponentNameLocked();
+        if (component == null) {
+            fail("Health check service not found");
+        }
+        Intent intent = new Intent();
+        intent.setComponent(component);
+        return intent;
+    }
+
+    private ComponentName getExtServiceComponentNameLocked() {
+        ServiceInfo serviceInfo = getExtServiceInfoLocked();
+        if (serviceInfo == null) {
+            return null;
+        }
+
+        final ComponentName name = new ComponentName(serviceInfo.packageName, serviceInfo.name);
+        if (!Manifest.permission.BIND_EXPLICIT_HEALTH_CHECK_SERVICE
+                .equals(serviceInfo.permission)) {
+            return null;
+        }
+        return name;
+    }
+
+    private ServiceInfo getExtServiceInfoLocked() {
+        final String packageName =
+                mContext.getPackageManager().getServicesSystemSharedLibraryPackageName();
+        if (packageName == null) {
+            return null;
+        }
+
+        final Intent intent = new Intent(ExplicitHealthCheckService.SERVICE_INTERFACE);
+        intent.setPackage(packageName);
+        final ResolveInfo resolveInfo = mContext.getPackageManager().resolveService(intent,
+                PackageManager.GET_SERVICES | PackageManager.GET_META_DATA);
+        if (resolveInfo == null || resolveInfo.serviceInfo == null) {
+            return null;
+        }
+        return resolveInfo.serviceInfo;
+    }
+}