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;
+ }
+}