Migrate LocaleNotification to main trunk

Bug: 248514263
Test: make RunSettingsRoboTests -j128 ROBOTEST_FILTER=AppLocalePickerActivityTest LocaleListEditorTest LocaleNotificationDataManagerTest NotificationCancelReceiverTest NotificationControllerTest
Change-Id: Iac7ffd493485be8ebb10ae63e5ca4ea7a57c8c78
diff --git a/Android.bp b/Android.bp
index c6a62a7..bb5ace8 100644
--- a/Android.bp
+++ b/Android.bp
@@ -80,6 +80,7 @@
         "androidx.lifecycle_lifecycle-runtime",
         "androidx.lifecycle_lifecycle-runtime-ktx",
         "androidx.lifecycle_lifecycle-viewmodel",
+        "gson",
         "guava",
         "jsr305",
         "net-utils-framework-common",
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index b147fff..412846c 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -2796,6 +2796,8 @@
                 android:exported="true"
                 android:permission="android.permission.MOUNT_UNMOUNT_FILESYSTEMS" />
 
+        <receiver android:name=".localepicker.NotificationCancelReceiver" />
+
         <activity android:name="Settings$ApnEditorActivity"
                 android:configChanges="orientation|keyboardHidden|screenSize"
                 android:exported="true"
diff --git a/src/com/android/settings/localepicker/AppLocalePickerActivity.java b/src/com/android/settings/localepicker/AppLocalePickerActivity.java
index 491f1a4..1c6a21a 100644
--- a/src/com/android/settings/localepicker/AppLocalePickerActivity.java
+++ b/src/com/android/settings/localepicker/AppLocalePickerActivity.java
@@ -18,13 +18,17 @@
 
 import android.app.FragmentTransaction;
 import android.app.LocaleManager;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
 import android.content.Intent;
-import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.LocaleList;
+import android.os.SystemClock;
 import android.os.SystemProperties;
+import android.provider.Settings;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.MenuItem;
@@ -32,8 +36,7 @@
 import android.widget.FrameLayout;
 import android.widget.ListView;
 
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.core.app.NotificationCompat;
 import androidx.core.view.ViewCompat;
 
 import com.android.internal.app.LocalePickerWithRegion;
@@ -43,19 +46,23 @@
 import com.android.settings.applications.appinfo.AppLocaleDetails;
 import com.android.settings.core.SettingsBaseActivity;
 
-import java.util.Locale;
-
 public class AppLocalePickerActivity extends SettingsBaseActivity
         implements LocalePickerWithRegion.LocaleSelectedListener, MenuItem.OnActionExpandListener {
     private static final String TAG = AppLocalePickerActivity.class.getSimpleName();
+    private static final String CHANNEL_ID_SUGGESTION = "suggestion";
+    private static final String CHANNEL_ID_SUGGESTION_TO_USER = "Locale suggestion";
+    private static final String EXTRA_SYSTEM_LOCALE_DIALOG_TYPE = "system_locale_dialog_type";
+    private static final String LOCALE_SUGGESTION = "locale_suggestion";
+    static final boolean ENABLED = false;
     static final String EXTRA_APP_LOCALE = "app_locale";
-    private static final String PROP_SYSTEM_LOCALE_SUGGESTION = "android.system.locale.suggestion";
-    private static final boolean ENABLED = false;
+    static final String EXTRA_NOTIFICATION_ID = "notification_id";
+    static final String PROP_SYSTEM_LOCALE_SUGGESTION = "android.system.locale.suggestion";
 
     private String mPackageName;
     private LocalePickerWithRegion mLocalePickerWithRegion;
     private AppLocaleDetails mAppLocaleDetails;
     private View mAppLocaleDetailContainer;
+    private NotificationController mNotificationController;
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
@@ -81,6 +88,7 @@
 
         setTitle(R.string.app_locale_picker_title);
         getActionBar().setDisplayHomeAsUpEnabled(true);
+        mNotificationController = NotificationController.getInstance(this);
 
         mLocalePickerWithRegion = LocalePickerWithRegion.createLanguagePicker(
                 this,
@@ -146,52 +154,78 @@
         if (!SystemProperties.getBoolean(PROP_SYSTEM_LOCALE_SUGGESTION, ENABLED)) {
             return;
         }
-        String languageTag = localeInfo.getLocale().toLanguageTag();
-        if (isInSystemLocale(languageTag) || localeInfo.isAppCurrentLocale()) {
+        String localeTag = localeInfo.getLocale().toLanguageTag();
+        if (LocaleUtils.isInSystemLocale(localeTag) || localeInfo.isAppCurrentLocale()) {
             return;
         }
-        String intentAction = getString(R.string.config_app_locale_intent_action);
-        if (!TextUtils.isEmpty(intentAction)) {
-            try {
-                PackageManager packageManager = getPackageManager();
-                ApplicationInfo info = packageManager.getApplicationInfo(mPackageName,
-                        PackageManager.GET_META_DATA);
-                Intent intent = new Intent(intentAction)
-                        .putExtra(Intent.EXTRA_UID, info.uid)
-                        .putExtra(EXTRA_APP_LOCALE, languageTag);
-                if (intent.resolveActivity(packageManager) != null) {
-                    mStartForResult.launch(intent);
-                }
-            } catch (PackageManager.NameNotFoundException e) {
-                Log.e(TAG, "Unable to find info for package: " + mPackageName);
+        try {
+            int uid = getPackageManager().getApplicationInfo(mPackageName,
+                    PackageManager.GET_META_DATA).uid;
+            boolean launchNotification = mNotificationController.shouldTriggerNotification(
+                    uid, localeTag);
+            if (launchNotification) {
+                triggerNotification(
+                        mNotificationController.getNotificationId(localeTag),
+                        getString(R.string.title_system_locale_addition,
+                                localeInfo.getFullNameNative()),
+                        getString(R.string.desc_system_locale_addition),
+                        localeTag);
             }
+        } catch (PackageManager.NameNotFoundException e) {
+            Log.e(TAG, "Unable to find info for package: " + mPackageName);
         }
     }
 
-    // Invoke startActivityFroResult so that the calling package can be shared via the intent.
-    private ActivityResultLauncher<Intent> mStartForResult = registerForActivityResult(
-            new ActivityResultContracts.StartActivityForResult(),
-            result -> {
-            }
-    );
+    private void triggerNotification(
+            int notificationId,
+            String title,
+            String description,
+            String localeTag) {
+        NotificationManager notificationManager = getSystemService(NotificationManager.class);
+        final boolean channelExist =
+                notificationManager.getNotificationChannel(CHANNEL_ID_SUGGESTION) != null;
 
-    /**
-     * Checks if the localeTag is in the system locale. Since in the current design, the system
-     * language list would not show two locales with the same language and region but different
-     * numbering system. So, during the comparison, the extension has to be stripped.
-     *
-     * @param languageTag A language tag
-     * @return true if the locale is in the system locale. Otherwise, false.
-     */
-    private static boolean isInSystemLocale(String languageTag) {
-        LocaleList systemLocales = LocaleList.getDefault();
-        Locale locale = Locale.forLanguageTag(languageTag).stripExtensions();
-        for (int i = 0; i < systemLocales.size(); i++) {
-            if (locale.equals(systemLocales.get(i).stripExtensions())) {
-                return true;
-            }
+        // Create an alert channel if it does not exist
+        if (!channelExist) {
+            NotificationChannel channel =
+                    new NotificationChannel(
+                            CHANNEL_ID_SUGGESTION,
+                            CHANNEL_ID_SUGGESTION_TO_USER,
+                            NotificationManager.IMPORTANCE_DEFAULT);
+            channel.setSound(/* sound */ null, /* audioAttributes */ null); // silent notification
+            notificationManager.createNotificationChannel(channel);
         }
-        return false;
+
+        final NotificationCompat.Builder builder =
+                new NotificationCompat.Builder(this, CHANNEL_ID_SUGGESTION)
+                        .setSmallIcon(R.drawable.ic_settings_language)
+                        .setAutoCancel(true)
+                        .setContentTitle(title)
+                        .setContentText(description)
+                        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+                        .setContentIntent(
+                                createPendingIntent(localeTag, notificationId, false))
+                        .setDeleteIntent(
+                                createPendingIntent(localeTag, notificationId, true));
+        notificationManager.notify(notificationId, builder.build());
+    }
+
+    private PendingIntent createPendingIntent(String locale, int notificationId,
+            boolean isDeleteIntent) {
+        Intent intent = isDeleteIntent
+                ? new Intent(this, NotificationCancelReceiver.class)
+                : new Intent(Settings.ACTION_LOCALE_SETTINGS)
+                        .putExtra(EXTRA_SYSTEM_LOCALE_DIALOG_TYPE, LOCALE_SUGGESTION)
+                        .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+
+        intent.putExtra(EXTRA_APP_LOCALE, locale)
+                .putExtra(EXTRA_NOTIFICATION_ID, notificationId);
+        int flag = PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT;
+        int elapsedTime = (int) SystemClock.elapsedRealtimeNanos();
+
+        return isDeleteIntent
+                ? PendingIntent.getBroadcast(this, elapsedTime, intent, flag)
+                : PendingIntent.getActivity(this, elapsedTime, intent, flag);
     }
 
     private View launchAppLocaleDetailsPage() {
diff --git a/src/com/android/settings/localepicker/LocaleDialogFragment.java b/src/com/android/settings/localepicker/LocaleDialogFragment.java
index 53846ba..91cbc87 100644
--- a/src/com/android/settings/localepicker/LocaleDialogFragment.java
+++ b/src/com/android/settings/localepicker/LocaleDialogFragment.java
@@ -51,6 +51,7 @@
 
     static final int DIALOG_CONFIRM_SYSTEM_DEFAULT = 1;
     static final int DIALOG_NOT_AVAILABLE_LOCALE = 2;
+    static final int DIALOG_ADD_SYSTEM_LOCALE = 3;
 
     static final String ARG_DIALOG_TYPE = "arg_dialog_type";
     static final String ARG_TARGET_LOCALE = "arg_target_locale";
@@ -95,7 +96,8 @@
             mShouldKeepDialog = savedInstanceState.getBoolean(ARG_SHOW_DIALOG, false);
             // Keep the dialog if user rotates the device, otherwise close the confirm system
             // default dialog only when user changes the locale.
-            if (type == DIALOG_CONFIRM_SYSTEM_DEFAULT && !mShouldKeepDialog) {
+            if ((type == DIALOG_CONFIRM_SYSTEM_DEFAULT || type == DIALOG_ADD_SYSTEM_LOCALE)
+                    && !mShouldKeepDialog) {
                 dismiss();
             }
         }
@@ -192,7 +194,8 @@
 
         @Override
         public void onClick(DialogInterface dialog, int which) {
-            if (mDialogType == DIALOG_CONFIRM_SYSTEM_DEFAULT) {
+            if (mDialogType == DIALOG_CONFIRM_SYSTEM_DEFAULT
+                    || mDialogType == DIALOG_ADD_SYSTEM_LOCALE) {
                 int result = Activity.RESULT_CANCELED;
                 boolean changed = false;
                 if (which == DialogInterface.BUTTON_POSITIVE) {
@@ -201,9 +204,12 @@
                 }
                 Intent intent = new Intent();
                 Bundle bundle = new Bundle();
-                bundle.putInt(ARG_DIALOG_TYPE, DIALOG_CONFIRM_SYSTEM_DEFAULT);
+                bundle.putInt(ARG_DIALOG_TYPE, mDialogType);
+                bundle.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, mLocaleInfo);
                 intent.putExtras(bundle);
-                mParent.onActivityResult(DIALOG_CONFIRM_SYSTEM_DEFAULT, result, intent);
+                mParent.onActivityResult(mDialogType, result, intent);
+                mMetricsFeatureProvider.action(mContext, SettingsEnums.ACTION_CHANGE_LANGUAGE,
+                        changed);
             }
             mShouldKeepDialog = false;
         }
@@ -227,6 +233,15 @@
                     dialogContent.mMessage = mContext.getString(R.string.desc_unavailable_locale);
                     dialogContent.mPositiveButton = mContext.getString(R.string.okay);
                     break;
+                case DIALOG_ADD_SYSTEM_LOCALE:
+                    dialogContent.mTitle = String.format(mContext.getString(
+                                    R.string.title_system_locale_addition),
+                            mLocaleInfo.getFullNameNative());
+                    dialogContent.mMessage = mContext.getString(
+                            R.string.desc_system_locale_addition);
+                    dialogContent.mPositiveButton = mContext.getString(R.string.add);
+                    dialogContent.mNegativeButton = mContext.getString(R.string.cancel);
+                    break;
                 default:
                     break;
             }
diff --git a/src/com/android/settings/localepicker/LocaleListEditor.java b/src/com/android/settings/localepicker/LocaleListEditor.java
index d8dd736..fe92af6 100644
--- a/src/com/android/settings/localepicker/LocaleListEditor.java
+++ b/src/com/android/settings/localepicker/LocaleListEditor.java
@@ -19,6 +19,8 @@
 import static android.os.UserManager.DISALLOW_CONFIG_LOCALE;
 
 import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_APP_LOCALE;
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_NOTIFICATION_ID;
+import static com.android.settings.localepicker.LocaleDialogFragment.DIALOG_ADD_SYSTEM_LOCALE;
 import static com.android.settings.localepicker.LocaleDialogFragment.DIALOG_CONFIRM_SYSTEM_DEFAULT;
 
 import android.app.Activity;
@@ -29,6 +31,7 @@
 import android.content.res.Resources;
 import android.os.Bundle;
 import android.os.LocaleList;
+import android.os.SystemProperties;
 import android.provider.Settings;
 import android.text.TextUtils;
 import android.util.Log;
@@ -59,7 +62,6 @@
 import com.android.settingslib.widget.LayoutPreference;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 
@@ -68,20 +70,22 @@
  */
 @SearchIndexable
 public class LocaleListEditor extends RestrictedSettingsFragment implements View.OnTouchListener {
-    private static final String TAG = LocaleListEditor.class.getSimpleName();
     protected static final String INTENT_LOCALE_KEY = "localeInfo";
+
+    private static final String TAG = LocaleListEditor.class.getSimpleName();
     private static final String CFGKEY_REMOVE_MODE = "localeRemoveMode";
     private static final String CFGKEY_REMOVE_DIALOG = "showingLocaleRemoveDialog";
     private static final String CFGKEY_ADD_LOCALE = "localeAdded";
-    private static final int MENU_ID_REMOVE = Menu.FIRST + 1;
-    private static final int REQUEST_LOCALE_PICKER = 0;
-
     private static final String INDEX_KEY_ADD_LANGUAGE = "add_language";
     private static final String KEY_LANGUAGES_PICKER = "languages_picker";
     private static final String TAG_DIALOG_CONFIRM_SYSTEM_DEFAULT = "dialog_confirm_system_default";
     private static final String TAG_DIALOG_NOT_AVAILABLE = "dialog_not_available_locale";
-    static final String EXTRA_SYSTEM_LOCALE_DIALOG_TYPE = "system_locale_dialog_type";
+    private static final String TAG_DIALOG_ADD_SYSTEM_LOCALE = "dialog_add_system_locale";
+    private static final String EXTRA_SYSTEM_LOCALE_DIALOG_TYPE = "system_locale_dialog_type";
     private static final String LOCALE_SUGGESTION = "locale_suggestion";
+    private static final int MENU_ID_REMOVE = Menu.FIRST + 1;
+    private static final int REQUEST_LOCALE_PICKER = 0;
+    private static final int INVALID_NOTIFICATION_ID = -1;
 
     private LocaleDragAndDropAdapter mAdapter;
     private Menu mMenu;
@@ -170,9 +174,10 @@
         if (mShowingRemoveDialog) {
             showRemoveLocaleWarningDialog();
         }
-        if (shouldShowConfirmationDialog() && !mLocaleAdditionMode) {
-            getActivity().setResult(Activity.RESULT_OK);
+        Log.d(TAG, "LocaleAdditionMode:" + mLocaleAdditionMode);
+        if (!mLocaleAdditionMode && shouldShowConfirmationDialog()) {
             showDialogForAddedLocale();
+            mLocaleAdditionMode = true;
         }
     }
 
@@ -236,18 +241,19 @@
                 mAdapter.notifyListChanged(localeInfo);
             }
             mAdapter.setCacheItemList();
+        } else if (requestCode == DIALOG_ADD_SYSTEM_LOCALE) {
+            if (resultCode == Activity.RESULT_OK) {
+                localeInfo = (LocaleStore.LocaleInfo) data.getExtras().getSerializable(
+                        LocaleDialogFragment.ARG_TARGET_LOCALE);
+                String preferencesTags = Settings.System.getString(
+                        getContext().getContentResolver(),
+                        Settings.System.LOCALE_PREFERENCES);
+                mAdapter.addLocale(mayAppendUnicodeTags(localeInfo, preferencesTags));
+            }
         }
         super.onActivityResult(requestCode, resultCode, data);
     }
 
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        if (mSuggestionDialog != null) {
-            mSuggestionDialog.dismiss();
-        }
-    }
-
     @VisibleForTesting
     static LocaleStore.LocaleInfo mayAppendUnicodeTags(
             LocaleStore.LocaleInfo localeInfo, String recordTags) {
@@ -276,31 +282,42 @@
         Intent intent = this.getIntent();
         String dialogType = intent.getStringExtra(EXTRA_SYSTEM_LOCALE_DIALOG_TYPE);
         String localeTag = intent.getStringExtra(EXTRA_APP_LOCALE);
-        if (!isAllowedPackage()
-                || isNullOrEmpty(dialogType)
-                || isNullOrEmpty(localeTag)
-                || !LOCALE_SUGGESTION.equals(dialogType)
+        int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, INVALID_NOTIFICATION_ID);
+        if (!isDialogFeatureEnabled()
+                || !isValidNotificationId(localeTag, notificationId)
+                || !isValidDialogType(dialogType)
                 || !isValidLocale(localeTag)
-                || isInSystemLocale(localeTag)) {
-            getActivity().setResult(Activity.RESULT_CANCELED);
+                || LocaleUtils.isInSystemLocale(localeTag)) {
             return false;
         }
-        getActivity().setResult(Activity.RESULT_OK);
         return true;
     }
 
-    private boolean isAllowedPackage() {
-        List<String> allowList = Arrays.asList(getContext().getResources().getStringArray(
-                R.array.allowed_packages_for_locale_confirmation_diallog));
-        String callingPackage = getActivity().getCallingPackage();
-        return !isNullOrEmpty(callingPackage) && allowList.contains(callingPackage);
+    private boolean isDialogFeatureEnabled() {
+        return SystemProperties.getBoolean(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION,
+                AppLocalePickerActivity.ENABLED);
     }
 
-    private static boolean isNullOrEmpty(String str) {
-        return str == null || str.isEmpty();
+    private boolean isValidNotificationId(String localeTag, long id) {
+        if (id == -1) {
+            return false;
+        }
+        return id == getNotificationController().getNotificationId(localeTag);
+    }
+
+    @VisibleForTesting
+    NotificationController getNotificationController() {
+        return NotificationController.getInstance(getContext());
+    }
+
+    private boolean isValidDialogType(String type) {
+        return LOCALE_SUGGESTION.equals(type);
     }
 
     private boolean isValidLocale(String tag) {
+        if (TextUtils.isEmpty(tag)) {
+            return false;
+        }
         String[] systemLocales = getSupportedLocales();
         for (String systemTag : systemLocales) {
             if (systemTag.equals(tag)) {
@@ -310,63 +327,26 @@
         return false;
     }
 
-    protected String[] getSupportedLocales() {
+    @VisibleForTesting
+    String[] getSupportedLocales() {
         return LocalePicker.getSupportedLocales(getContext());
     }
 
-    /**
-     *  Check if the localeTag is in the system locale. Since in the current design, the system
-     *  language list would not show two locales with the same language and region but different
-     *  numbering system. So, during the comparison, the u extension has to be stripped out.
-     *
-     * @param languageTag A language tag
-     * @return true if the locale is in the system locale. Otherwise, false.
-     */
-    private boolean isInSystemLocale(String languageTag) {
-        LocaleList systemLocales = LocaleList.getDefault();
-        Locale locale = Locale.forLanguageTag(languageTag).stripExtensions();
-        for (int i = 0; i < systemLocales.size(); i++) {
-            if (systemLocales.get(i).stripExtensions().equals(locale)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     private void showDialogForAddedLocale() {
+        Log.d(TAG, "Show confirmation dialog");
         Intent intent = this.getIntent();
         String dialogType = intent.getStringExtra(EXTRA_SYSTEM_LOCALE_DIALOG_TYPE);
         String appLocaleTag = intent.getStringExtra(EXTRA_APP_LOCALE);
-        Log.d(TAG, "Dialog suggested locale: " + appLocaleTag);
+
         LocaleStore.LocaleInfo localeInfo = LocaleStore.getLocaleInfo(
                 Locale.forLanguageTag(appLocaleTag));
-        if (LOCALE_SUGGESTION.equals(dialogType)) {
-            AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity());
-            customizeLayout(dialogBuilder, localeInfo.getFullNameNative());
-            dialogBuilder
-                    .setPositiveButton(R.string.add, new DialogInterface.OnClickListener() {
-                        @Override
-                        public void onClick(DialogInterface dialog, int which) {
-                            mLocaleAdditionMode = true;
-                            String preferencesTags = Settings.System.getString(
-                                    getContext().getContentResolver(),
-                                    Settings.System.LOCALE_PREFERENCES);
-                            mAdapter.addLocale(mayAppendUnicodeTags(localeInfo, preferencesTags));
-                        }
-                    })
-                    .setNegativeButton(android.R.string.cancel,
-                            new DialogInterface.OnClickListener() {
-                                @Override
-                                public void onClick(DialogInterface dialog, int which) {
-                                    mLocaleAdditionMode = true;
-                                }
-                            });
-            mSuggestionDialog = dialogBuilder.create();
-            mSuggestionDialog.setCanceledOnTouchOutside(false);
-            mSuggestionDialog.show();
-        } else {
-            Log.d(TAG, "Invalid parameter, dialogType:" + dialogType);
-        }
+        final LocaleDialogFragment localeDialogFragment =
+                LocaleDialogFragment.newInstance();
+        Bundle args = new Bundle();
+        args.putInt(LocaleDialogFragment.ARG_DIALOG_TYPE, DIALOG_ADD_SYSTEM_LOCALE);
+        args.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, localeInfo);
+        localeDialogFragment.setArguments(args);
+        localeDialogFragment.show(mFragmentManager, TAG_DIALOG_ADD_SYSTEM_LOCALE);
     }
 
     private void customizeLayout(AlertDialog.Builder dialogBuilder, String language) {
diff --git a/src/com/android/settings/localepicker/LocaleNotificationDataManager.java b/src/com/android/settings/localepicker/LocaleNotificationDataManager.java
new file mode 100644
index 0000000..09d6280
--- /dev/null
+++ b/src/com/android/settings/localepicker/LocaleNotificationDataManager.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.google.gson.Gson;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A data manager that manages the {@link SharedPreferences} for the locale notification
+ * information.
+ */
+public class LocaleNotificationDataManager {
+    private static final String LOCALE_NOTIFICATION = "locale_notification";
+    private Context mContext;
+
+    /**
+     * Constructor
+     *
+     * @param context The context
+     */
+    public LocaleNotificationDataManager(Context context) {
+        this.mContext = context;
+    }
+
+    private static SharedPreferences getSharedPreferences(Context context) {
+        return context.getSharedPreferences(LOCALE_NOTIFICATION, Context.MODE_PRIVATE);
+    }
+
+    /**
+     * Adds one entry with the corresponding locale and {@link NotificationInfo} to the
+     * {@link SharedPreferences}.
+     *
+     * @param locale A locale which the application sets to
+     * @param info   The notification metadata
+     */
+    public void putNotificationInfo(String locale, NotificationInfo info) {
+        Gson gson = new Gson();
+        String json = gson.toJson(info);
+        SharedPreferences.Editor editor = getSharedPreferences(mContext).edit();
+        editor.putString(locale, json);
+        editor.apply();
+    }
+
+    /**
+     * Gets the {@link NotificationInfo} with the associated locale from the
+     * {@link SharedPreferences}.
+     *
+     * @param locale A locale which the application sets to
+     * @return {@link NotificationInfo}
+     */
+    public NotificationInfo getNotificationInfo(String locale) {
+        Gson gson = new Gson();
+        String json = getSharedPreferences(mContext).getString(locale, "");
+        return json.isEmpty() ? null : gson.fromJson(json, NotificationInfo.class);
+    }
+
+    /**
+     * Gets the locale notification map.
+     *
+     * @return A map which maps the locale to the corresponding {@link NotificationInfo}
+     */
+    public Map<String, NotificationInfo> getLocaleNotificationInfoMap() {
+        Gson gson = new Gson();
+        Map<String, String> map = (Map<String, String>) getSharedPreferences(mContext).getAll();
+        Map<String, NotificationInfo> result = new HashMap<>(map.size());
+        map.forEach((key, value) -> {
+            result.put(key, gson.fromJson(value, NotificationInfo.class));
+        });
+        return result;
+    }
+
+    /**
+     * Clears the locale notification map.
+     */
+    @VisibleForTesting
+    void clearLocaleNotificationMap() {
+        getSharedPreferences(mContext).edit().clear().apply();
+    }
+}
diff --git a/src/com/android/settings/localepicker/LocaleUtils.java b/src/com/android/settings/localepicker/LocaleUtils.java
new file mode 100644
index 0000000..a84d0be
--- /dev/null
+++ b/src/com/android/settings/localepicker/LocaleUtils.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import android.os.LocaleList;
+
+import androidx.annotation.NonNull;
+
+import java.util.Locale;
+
+/**
+ * A locale utility class.
+ */
+public class LocaleUtils {
+    /**
+     * Checks if the languageTag is in the system locale. Since in the current design, the system
+     * language list would not show two locales with the same language and region but different
+     * numbering system. So, the u extension has to be stripped out in the process of comparison.
+     *
+     * @param languageTag A language tag
+     * @return true if the locale is in the system locale. Otherwise, false.
+     */
+    public static boolean isInSystemLocale(@NonNull String languageTag) {
+        LocaleList systemLocales = LocaleList.getDefault();
+        Locale localeWithoutUextension =
+                new Locale.Builder()
+                        .setLocale(Locale.forLanguageTag(languageTag))
+                        .clearExtensions()
+                        .build();
+        for (int i = 0; i < systemLocales.size(); i++) {
+            Locale sysLocaleWithoutUextension =
+                    new Locale.Builder().setLocale(systemLocales.get(i)).clearExtensions().build();
+            if (localeWithoutUextension.equals(sysLocaleWithoutUextension)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/src/com/android/settings/localepicker/NotificationCancelReceiver.java b/src/com/android/settings/localepicker/NotificationCancelReceiver.java
new file mode 100644
index 0000000..f51dfb3
--- /dev/null
+++ b/src/com/android/settings/localepicker/NotificationCancelReceiver.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_APP_LOCALE;
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_NOTIFICATION_ID;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+/**
+ * A Broadcast receiver that handles the locale notification which is swiped away.
+ */
+public class NotificationCancelReceiver extends BroadcastReceiver {
+    private static final String TAG = NotificationCancelReceiver.class.getSimpleName();
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String appLocale = intent.getExtras().getString(EXTRA_APP_LOCALE);
+        int notificationId = intent.getExtras().getInt(EXTRA_NOTIFICATION_ID, -1);
+        int savedNotificationID = getNotificationController(context).getNotificationId(
+                appLocale);
+        Log.i(TAG, "Locale notification is swiped away.");
+        if (savedNotificationID == notificationId) {
+            getNotificationController(context).incrementDismissCount(appLocale);
+        }
+    }
+
+    @VisibleForTesting
+    NotificationController getNotificationController(Context context) {
+        return NotificationController.getInstance(context);
+    }
+}
diff --git a/src/com/android/settings/localepicker/NotificationController.java b/src/com/android/settings/localepicker/NotificationController.java
new file mode 100644
index 0000000..2d36189
--- /dev/null
+++ b/src/com/android/settings/localepicker/NotificationController.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.os.SystemProperties;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+
+import java.util.Calendar;
+import java.util.Set;
+
+/**
+ * A controller that evaluates whether the notification can be triggered and update the
+ * SharedPreference.
+ */
+public class NotificationController {
+    private static final String TAG = NotificationController.class.getSimpleName();
+    private static final int DISMISS_COUNT_THRESHOLD = 2;
+    private static final int NOTIFICATION_COUNT_THRESHOLD = 2;
+    private static final int MULTIPLE_BASE = 2;
+    // seven days: 7 * 24 * 60
+    private static final int MIN_DURATION_BETWEEN_NOTIFICATIONS_MIN = 10080;
+    private static final String PROPERTY_MIN_DURATION =
+            "android.localenotification.duration.threshold";
+
+    private static NotificationController sInstance = null;
+
+    private final LocaleNotificationDataManager mDataManager;
+
+    /**
+     * Get {@link NotificationController} instance.
+     *
+     * @param context The context
+     * @return {@link NotificationController} instance
+     */
+    public static synchronized NotificationController getInstance(@NonNull Context context) {
+        if (sInstance == null) {
+            sInstance = new NotificationController(context);
+        }
+        return sInstance;
+    }
+
+    private NotificationController(Context context) {
+        mDataManager = new LocaleNotificationDataManager(context);
+    }
+
+    @VisibleForTesting
+    LocaleNotificationDataManager getDataManager() {
+        return mDataManager;
+    }
+
+    /**
+     * Increment the dismissCount of the notification.
+     *
+     * @param locale A locale used to query the {@link NotificationInfo}
+     */
+    public void incrementDismissCount(@NonNull String locale) {
+        NotificationInfo currentInfo = mDataManager.getNotificationInfo(locale);
+        NotificationInfo newInfo = new NotificationInfo(currentInfo.getUidCollection(),
+                currentInfo.getNotificationCount(),
+                currentInfo.getDismissCount() + 1,
+                currentInfo.getLastNotificationTimeMs(),
+                currentInfo.getNotificationId());
+        mDataManager.putNotificationInfo(locale, newInfo);
+    }
+
+    /**
+     * Whether the notification can be triggered or not.
+     *
+     * @param uid     The application's uid.
+     * @param locale  The application's locale which the user updated to.
+     * @return true if the notification needs to be triggered. Otherwise, false.
+     */
+    public boolean shouldTriggerNotification(int uid, @NonNull String locale) {
+        if (LocaleUtils.isInSystemLocale(locale)) {
+            return false;
+        } else {
+            // Add the uid into the locale's uid list and update the notification count if the
+            // notification can be triggered.
+            return updateLocaleNotificationInfo(uid, locale);
+        }
+    }
+
+    /**
+     * Get the notification id
+     *
+     * @param locale The locale which the application sets to
+     * @return the notification id
+     */
+    public int getNotificationId(@NonNull String locale) {
+        NotificationInfo info = mDataManager.getNotificationInfo(locale);
+        return (info != null) ? info.getNotificationId() : -1;
+    }
+
+    private boolean updateLocaleNotificationInfo(int uid, String locale) {
+        NotificationInfo info = mDataManager.getNotificationInfo(locale);
+        if (info == null) {
+            // Create an empty record with the uid and update the SharedPreference.
+            NotificationInfo emptyInfo = new NotificationInfo(Set.of(uid), 0, 0, 0, 0);
+            mDataManager.putNotificationInfo(locale, emptyInfo);
+            return false;
+        }
+        Set uidCollection = info.getUidCollection();
+        if (uidCollection.contains(uid)) {
+            return false;
+        }
+
+        NotificationInfo newInfo =
+                createNotificationInfoWithNewUidAndCount(uidCollection, uid, info);
+        mDataManager.putNotificationInfo(locale, newInfo);
+        return newInfo.getNotificationCount() > info.getNotificationCount();
+    }
+
+    private NotificationInfo createNotificationInfoWithNewUidAndCount(
+            Set<Integer> uidSet, int uid, NotificationInfo info) {
+        int dismissCount = info.getDismissCount();
+        int notificationCount = info.getNotificationCount();
+        long lastNotificationTime = info.getLastNotificationTimeMs();
+        int notificationId = info.getNotificationId();
+
+        // Add the uid into the locale's uid list
+        uidSet.add(uid);
+        if (dismissCount < DISMISS_COUNT_THRESHOLD
+                && notificationCount < NOTIFICATION_COUNT_THRESHOLD
+                // Notification should fire on multiples of 2 apps using the locale.
+                && uidSet.size() % MULTIPLE_BASE == 0
+                && !isNotificationFrequent(lastNotificationTime)) {
+            // Increment the count because the notification can be triggered.
+            notificationCount = info.getNotificationCount() + 1;
+            lastNotificationTime = Calendar.getInstance().getTimeInMillis();
+            Log.i(TAG, "notificationCount:" + notificationCount);
+            if (notificationCount == 1) {
+                notificationId = (int) SystemClock.uptimeMillis();
+            }
+        }
+        return new NotificationInfo(uidSet, notificationCount, dismissCount, lastNotificationTime,
+                notificationId);
+    }
+
+    /**
+     * Evaluates if the notification is triggered frequently.
+     *
+     * @param lastNotificationTime The timestamp that the last notification was triggered.
+     * @return true if the duration of the two continuous notifications is smaller than the
+     * threshold.
+     * Otherwise, false.
+     */
+    private boolean isNotificationFrequent(long lastNotificationTime) {
+        Calendar time = Calendar.getInstance();
+        int threshold = SystemProperties.getInt(PROPERTY_MIN_DURATION,
+                MIN_DURATION_BETWEEN_NOTIFICATIONS_MIN);
+        time.add(Calendar.MINUTE, threshold * -1);
+        return time.getTimeInMillis() < lastNotificationTime;
+    }
+}
diff --git a/src/com/android/settings/localepicker/NotificationInfo.java b/src/com/android/settings/localepicker/NotificationInfo.java
new file mode 100644
index 0000000..8390826
--- /dev/null
+++ b/src/com/android/settings/localepicker/NotificationInfo.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import java.util.Objects;
+import java.util.Set;
+
+class NotificationInfo {
+    private Set<Integer> mUidCollection;
+    private int mNotificationCount;
+    private int mDismissCount;
+    private long mLastNotificationTimeMs;
+    private int mNotificationId;
+
+    private NotificationInfo() {
+    }
+
+    NotificationInfo(Set<Integer> uidCollection, int notificationCount, int dismissCount,
+            long lastNotificationTimeMs, int notificationId) {
+        this.mUidCollection = uidCollection;
+        this.mNotificationCount = notificationCount;
+        this.mDismissCount = dismissCount;
+        this.mLastNotificationTimeMs = lastNotificationTimeMs;
+        this.mNotificationId = notificationId;
+    }
+
+    public Set<Integer> getUidCollection() {
+        return mUidCollection;
+    }
+
+    public int getNotificationCount() {
+        return mNotificationCount;
+    }
+
+    public int getDismissCount() {
+        return mDismissCount;
+    }
+
+    public long getLastNotificationTimeMs() {
+        return mLastNotificationTimeMs;
+    }
+
+    public int getNotificationId() {
+        return mNotificationId;
+    }
+
+    public void setUidCollection(Set<Integer> uidCollection) {
+        this.mUidCollection = uidCollection;
+    }
+
+    public void setNotificationCount(int notificationCount) {
+        this.mNotificationCount = notificationCount;
+    }
+
+    public void setDismissCount(int dismissCount) {
+        this.mDismissCount = dismissCount;
+    }
+
+    public void setLastNotificationTimeMs(long lastNotificationTimeMs) {
+        this.mLastNotificationTimeMs = lastNotificationTimeMs;
+    }
+
+    public void setNotificationId(int notificationId) {
+        this.mNotificationId = notificationId;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null) return false;
+        if (this == o) return true;
+        if (!(o instanceof NotificationInfo)) return false;
+        NotificationInfo that = (NotificationInfo) o;
+        return (mUidCollection.equals(that.mUidCollection))
+                && (mDismissCount == that.mDismissCount)
+                && (mNotificationCount == that.mNotificationCount)
+                && (mLastNotificationTimeMs == that.mLastNotificationTimeMs)
+                && (mNotificationId == that.mNotificationId);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mUidCollection, mDismissCount, mNotificationCount,
+                mLastNotificationTimeMs, mNotificationId);
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/localepicker/AppLocalePickerActivityTest.java b/tests/robotests/src/com/android/settings/localepicker/AppLocalePickerActivityTest.java
index 48caecd..d711ad6 100644
--- a/tests/robotests/src/com/android/settings/localepicker/AppLocalePickerActivityTest.java
+++ b/tests/robotests/src/com/android/settings/localepicker/AppLocalePickerActivityTest.java
@@ -32,11 +32,14 @@
 import android.content.pm.ApplicationInfo;
 import android.content.pm.InstallSourceInfo;
 import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
 import android.net.Uri;
 import android.os.LocaleList;
 import android.os.Process;
+import android.os.SystemClock;
+import android.os.SystemProperties;
 import android.os.UserHandle;
 import android.telephony.TelephonyManager;
 
@@ -67,8 +70,10 @@
 import org.robolectric.util.ReflectionHelpers;
 
 import java.util.ArrayList;
+import java.util.Calendar;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 @RunWith(RobolectricTestRunner.class)
 @Config(
@@ -79,6 +84,12 @@
 public class AppLocalePickerActivityTest {
     private static final String TEST_PACKAGE_NAME = "com.android.settings";
     private static final Uri TEST_PACKAGE_URI = Uri.parse("package:" + TEST_PACKAGE_NAME);
+    private static final String EN_CA = "en-CA";
+    private static final String EN_US = "en-US";
+    private static int sUid;
+
+    private LocaleNotificationDataManager mDataManager;
+    private AppLocalePickerActivity mActivity;
 
     @Mock
     LocaleStore.LocaleInfo mLocaleInfo;
@@ -99,10 +110,11 @@
         when(mLocaleConfig.getStatus()).thenReturn(LocaleConfig.STATUS_SUCCESS);
         when(mLocaleConfig.getSupportedLocales()).thenReturn(LocaleList.forLanguageTags("en-US"));
         ReflectionHelpers.setStaticField(AppLocaleUtil.class, "sLocaleConfig", mLocaleConfig);
+        sUid = Process.myUid();
     }
 
     @After
-    public void tearDown() {
+    public void tearDown() throws Exception {
         mPackageManager.removePackage(TEST_PACKAGE_NAME);
         ReflectionHelpers.setStaticField(AppLocaleUtil.class, "sLocaleConfig", null);
         ShadowResources.setDisAllowPackage(false);
@@ -210,13 +222,266 @@
         assertThat(controller.get().isFinishing()).isTrue();
     }
 
+    @Test
+    public void onLocaleSelected_evaluateNotification_simpleLocaleUpdate_localeCreatedWithUid()
+            throws Exception {
+        sUid = 100;
+        initLocaleNotificationEnvironment();
+        ActivityController<TestAppLocalePickerActivity> controller = initActivityController(true);
+        controller.create();
+        AppLocalePickerActivity mActivity = controller.get();
+        LocaleNotificationDataManager dataManager =
+                NotificationController.getInstance(mActivity).getDataManager();
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered.
+        // In the sharedpreference, en-US's uid list contains uid1 and the notificationCount
+        // equals 0.
+        NotificationInfo info = dataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection().contains(sUid)).isTrue();
+        assertThat(info.getNotificationCount()).isEqualTo(0);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isEqualTo(0);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void onLocaleSelected_evaluateNotification_twoLocaleUpdate_triggerNotification()
+            throws Exception {
+        // App with uid 101 changed its locale from System to en-US.
+        sUid = 101;
+        initLocaleNotificationEnvironment();
+        // Initialize the proto to contain en-US locale. Its uid list includes 100.
+        Set<Integer> uidSet = Set.of(100);
+        initSharedPreference(EN_US, uidSet, 0, 0, 0, 0);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is triggered.
+        // In the proto file, en-US's uid list contains 101, the notificationCount equals 1, and
+        // LastNotificationTime > 0.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(1);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isNotEqualTo(0);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void onLocaleSelected_evaluateNotification_oddLocaleUpdate_uidAddedWithoutNotification()
+            throws Exception {
+        // App with uid 102 changed its locale from System to en-US.
+        sUid = 102;
+        initLocaleNotificationEnvironment();
+        // Initialize the proto to include en-US locale. Its uid list includes 100,101 and
+        // the notification count equals 1.
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101);
+        initSharedPreference(EN_US, uidSet, 0, 1,
+                Calendar.getInstance().getTimeInMillis(), notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered because count % 2 != 0.
+        // In the proto file, en-US's uid list contains 102, the notificationCount equals 1, and
+        // LastNotificationTime > 0.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(1);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isNotEqualTo(0);
+        assertThat(info.getNotificationId()).isEqualTo(notificationId);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void onLocaleSelected_evaluateNotification_frequentLocaleUpdate_uidAddedNoNotification()
+            throws Exception {
+        // App with uid 103 changed its locale from System to en-US.
+        sUid = 103;
+        initLocaleNotificationEnvironment();
+        // Initialize the proto to include en-US locale. Its uid list includes 100,101,102 and
+        // the notification count equals 1.
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101, 102);
+        initSharedPreference(EN_US, uidSet, 0, 1,
+                Calendar.getInstance().getTimeInMillis(), notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered because the duration is less than the threshold.
+        // In the proto file, en-US's uid list contains 103, the notificationCount equals 1, and
+        // LastNotificationTime > 0.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection().contains(sUid)).isTrue();
+        assertThat(info.getNotificationCount()).isEqualTo(1);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isNotEqualTo(0);
+        assertThat(info.getNotificationId()).isEqualTo(notificationId);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void onLocaleSelected_evaluateNotification_2ndOddLocaleUpdate_uidAddedNoNotification()
+            throws Exception {
+        // App with uid 104 changed its locale from System to en-US.
+        sUid = 104;
+        initLocaleNotificationEnvironment();
+
+        // Initialize the proto to include en-US locale. Its uid list includes 100,101,102,103 and
+        // the notification count equals 1.
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101, 102, 103);
+        initSharedPreference(EN_US, uidSet, 0, 1, Calendar.getInstance().getTimeInMillis(),
+                notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered because uid count % 2 != 0
+        // In the proto file, en-US's uid list contains uid4, the notificationCount equals 1, and
+        // LastNotificationTime > 0.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(1);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isNotEqualTo(0);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void testEvaluateLocaleNotification_evenLocaleUpdate_trigger2ndNotification()
+            throws Exception {
+        sUid = 105;
+        initLocaleNotificationEnvironment();
+
+        // Initialize the proto to include en-US locale. Its uid list includes 100,101,102,103,104
+        // and the notification count equals 1.
+        // Eight days later, App with uid 105 changed its locale from System to en-US
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101, 102, 103, 104);
+        Calendar now = Calendar.getInstance();
+        now.add(Calendar.DAY_OF_MONTH, -8); // Set the lastNotificationTime to eight days ago.
+        long lastNotificationTime = now.getTimeInMillis();
+        initSharedPreference(EN_US, uidSet, 0, 1, lastNotificationTime, notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is triggered.
+        // In the proto file, en-US's uid list contains 105, the notificationCount equals 2, and
+        // LastNotificationTime is updated.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(2);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isGreaterThan(lastNotificationTime);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void testEvaluateLocaleNotification_localeUpdateReachThreshold_uidAddedNoNotification()
+            throws Exception {
+        // App with uid 106 changed its locale from System to en-US.
+        sUid = 106;
+        initLocaleNotificationEnvironment();
+        // Initialize the proto to include en-US locale. Its uid list includes
+        // 100,101,102,103,104,105 and the notification count equals 2.
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101, 102, 103, 104, 105);
+        Calendar now = Calendar.getInstance();
+        now.add(Calendar.DAY_OF_MONTH, -8);
+        long lastNotificationTime = now.getTimeInMillis();
+        initSharedPreference(EN_US, uidSet, 0, 2, lastNotificationTime, notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered because the notification count threshold, 2, is reached.
+        // In the proto file, en-US's uid list contains 106, the notificationCount equals 2, and
+        // LastNotificationTime > 0.
+        NotificationInfo info = mDataManager.getNotificationInfo(EN_US);
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(2);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isEqualTo(lastNotificationTime);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void testEvaluateLocaleNotification_appChangedLocales_newLocaleCreated()
+            throws Exception {
+        sUid = 100;
+        initLocaleNotificationEnvironment();
+        // App with uid 100 changed its locale from en-US to ja-JP.
+        Locale locale = Locale.forLanguageTag("ja-JP");
+        when(mLocaleInfo.getLocale()).thenReturn(locale);
+        // Initialize the proto to include en-US locale. Its uid list includes
+        // 100,101,102,103,104,105,106 and the notification count equals 2.
+        int notificationId = (int) SystemClock.uptimeMillis();
+        Set<Integer> uidSet = Set.of(100, 101, 102, 103, 104, 105, 106);
+        Calendar now = Calendar.getInstance();
+        now.add(Calendar.DAY_OF_MONTH, -8);
+        initSharedPreference(EN_US, uidSet, 0, 2, now.getTimeInMillis(),
+                notificationId);
+
+        mActivity.onLocaleSelected(mLocaleInfo);
+
+        // Notification is not triggered
+        // In the proto file, a map for ja-JP is created. Its uid list contains uid1.
+        NotificationInfo info = mDataManager.getNotificationInfo("ja-JP");
+        assertThat(info.getUidCollection()).contains(sUid);
+        assertThat(info.getNotificationCount()).isEqualTo(0);
+        assertThat(info.getDismissCount()).isEqualTo(0);
+        assertThat(info.getLastNotificationTimeMs()).isEqualTo(0);
+
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "false");
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    private void initLocaleNotificationEnvironment() throws Exception {
+        LocaleList.setDefault(LocaleList.forLanguageTags(EN_CA));
+        SystemProperties.set(AppLocalePickerActivity.PROP_SYSTEM_LOCALE_SUGGESTION, "true");
+
+        Locale locale = Locale.forLanguageTag("en-US");
+        when(mLocaleInfo.getLocale()).thenReturn(locale);
+        when(mLocaleInfo.isSystemLocale()).thenReturn(false);
+        when(mLocaleInfo.isAppCurrentLocale()).thenReturn(false);
+
+        ActivityController<TestAppLocalePickerActivity> controller = initActivityController(true);
+        controller.create();
+        mActivity = controller.get();
+        mDataManager = NotificationController.getInstance(mActivity).getDataManager();
+    }
+
+    private void initSharedPreference(String locale, Set<Integer> uidSet, int dismissCount,
+            int notificationCount, long lastNotificationTime, int notificationId)
+            throws Exception {
+        NotificationInfo info = new NotificationInfo(uidSet, notificationCount, dismissCount,
+                lastNotificationTime, notificationId);
+        mDataManager.putNotificationInfo(locale, info);
+    }
+
     private ActivityController<TestAppLocalePickerActivity> initActivityController(
             boolean hasPackageName) {
         Intent data = new Intent();
         if (hasPackageName) {
             data.setData(TEST_PACKAGE_URI);
         }
-        data.putExtra(AppInfoBase.ARG_PACKAGE_UID, Process.myUid());
+        data.putExtra(AppInfoBase.ARG_PACKAGE_UID, sUid);
         ActivityController<TestAppLocalePickerActivity> activityController =
                 Robolectric.buildActivity(TestAppLocalePickerActivity.class, data);
         Activity activity = activityController.get();
@@ -259,6 +524,19 @@
         private static void setNoLaunchEntry(boolean noLaunchEntry) {
             sNoLaunchEntry = noLaunchEntry;
         }
+
+        @Implementation
+        protected ApplicationInfo getApplicationInfo(String packageName, int flags)
+                throws NameNotFoundException {
+            if (packageName.equals(TEST_PACKAGE_NAME)) {
+                ApplicationInfo applicationInfo = new ApplicationInfo();
+                applicationInfo.packageName = TEST_PACKAGE_NAME;
+                applicationInfo.uid = sUid;
+                return applicationInfo;
+            } else {
+                return super.getApplicationInfo(packageName, flags);
+            }
+        }
     }
 
     @Implements(Resources.class)
diff --git a/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java b/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java
index df7fa40..f0d629d 100644
--- a/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java
+++ b/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java
@@ -17,7 +17,8 @@
 package com.android.settings.localepicker;
 
 import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_APP_LOCALE;
-import static com.android.settings.localepicker.LocaleListEditor.EXTRA_SYSTEM_LOCALE_DIALOG_TYPE;
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_NOTIFICATION_ID;
+import static com.android.settings.localepicker.LocaleDialogFragment.DIALOG_ADD_SYSTEM_LOCALE;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -29,7 +30,6 @@
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
-import static org.robolectric.Shadows.shadowOf;
 
 import android.app.Activity;
 import android.app.IActivityManager;
@@ -44,7 +44,6 @@
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.Button;
 import android.widget.CheckBox;
 import android.widget.FrameLayout;
 import android.widget.ImageView;
@@ -91,6 +90,8 @@
     private static final String ARG_DIALOG_TYPE = "arg_dialog_type";
     private static final String TAG_DIALOG_CONFIRM_SYSTEM_DEFAULT = "dialog_confirm_system_default";
     private static final String TAG_DIALOG_NOT_AVAILABLE = "dialog_not_available_locale";
+    private static final String TAG_DIALOG_ADD_SYSTEM_LOCALE = "dialog_add_system_locale";
+    private static final String EXTRA_SYSTEM_LOCALE_DIALOG_TYPE = "system_locale_dialog_type";
     private static final int DIALOG_CONFIRM_SYSTEM_DEFAULT = 1;
     private static final int REQUEST_CONFIRM_SYSTEM_DEFAULT = 1;
 
@@ -132,6 +133,8 @@
     private TextView mCurrentDefault;
     @Mock
     private ImageView mDragHandle;
+    @Mock
+    private NotificationController mNotificationController;
 
     @Before
     public void setUp() throws Exception {
@@ -141,6 +144,8 @@
         when(mLocaleListEditor.getContext()).thenReturn(mContext);
         mActivity = Robolectric.buildActivity(FragmentActivity.class).get();
         when(mLocaleListEditor.getActivity()).thenReturn(mActivity);
+        when(mLocaleListEditor.getNotificationController()).thenReturn(
+                mNotificationController);
         ReflectionHelpers.setField(mLocaleListEditor, "mEmptyTextView",
                 new TextView(RuntimeEnvironment.application));
         ReflectionHelpers.setField(mLocaleListEditor, "mRestrictionsManager",
@@ -345,24 +350,21 @@
         initIntentAndResourceForLocaleDialog();
         mLocaleListEditor.onViewStateRestored(null);
 
-        final AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
-        assertThat(dialog).isNotNull();
-        final ShadowAlertDialogCompat shadowDialog = ShadowAlertDialogCompat.shadowOf(dialog);
-        assertThat(shadowDialog.getView()).isNotNull();
-        TextView message = shadowDialog.getView().findViewById(R.id.dialog_msg);
-        assertThat(message.getText().toString()).isEqualTo(
-                "This lets apps and websites know you also prefer this language.");
+        verify(mFragmentTransaction).add(any(LocaleDialogFragment.class),
+                eq(TAG_DIALOG_ADD_SYSTEM_LOCALE));
     }
 
     @Test
     public void showDiallogForAddedLocale_clickAdd() {
         initIntentAndResourceForLocaleDialog();
         mLocaleListEditor.onViewStateRestored(null);
+        LocaleStore.LocaleInfo info = LocaleStore.fromLocale(Locale.forLanguageTag("en-US"));
+        Bundle bundle = new Bundle();
+        bundle.putInt(ARG_DIALOG_TYPE, DIALOG_ADD_SYSTEM_LOCALE);
+        bundle.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, info);
+        Intent intent = new Intent().putExtras(bundle);
+        mLocaleListEditor.onActivityResult(DIALOG_ADD_SYSTEM_LOCALE, Activity.RESULT_OK, intent);
 
-        final AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
-        assertThat(dialog).isNotNull();
-        Button positive = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
-        positive.performClick();
         verify(mAdapter).addLocale(any(LocaleStore.LocaleInfo.class));
     }
 
@@ -370,11 +372,14 @@
     public void showDiallogForAddedLocale_clickCancel() {
         initIntentAndResourceForLocaleDialog();
         mLocaleListEditor.onViewStateRestored(null);
+        LocaleStore.LocaleInfo info = LocaleStore.fromLocale(Locale.forLanguageTag("en-US"));
+        Bundle bundle = new Bundle();
+        bundle.putInt(ARG_DIALOG_TYPE, DIALOG_ADD_SYSTEM_LOCALE);
+        bundle.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, info);
+        Intent intent = new Intent().putExtras(bundle);
+        mLocaleListEditor.onActivityResult(DIALOG_ADD_SYSTEM_LOCALE, Activity.RESULT_CANCELED,
+                intent);
 
-        final AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
-        assertThat(dialog).isNotNull();
-        Button negative = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
-        negative.performClick();
         verify(mAdapter, never()).addLocale(any(LocaleStore.LocaleInfo.class));
     }
 
@@ -419,25 +424,17 @@
     }
 
     private void initIntentAndResourceForLocaleDialog() {
+        int notificationId = 1000;
         Intent intent = new Intent("ACTION")
                 .putExtra(EXTRA_APP_LOCALE, "ja-JP")
-                .putExtra(EXTRA_SYSTEM_LOCALE_DIALOG_TYPE, "locale_suggestion");
+                .putExtra(EXTRA_SYSTEM_LOCALE_DIALOG_TYPE, "locale_suggestion")
+                .putExtra(EXTRA_NOTIFICATION_ID, notificationId);
+
         mActivity.setIntent(intent);
-        shadowOf(mActivity).setCallingPackage("com.a.b");
-        String[] allowedPackage = new String[]{"com.a.b", "com.b.c"};
         String[] supportedLocales = new String[]{"en-US", "ja-JP"};
         View contentView = LayoutInflater.from(mActivity).inflate(R.layout.locale_dialog, null);
         doReturn(contentView).when(mLocaleListEditor).getLocaleDialogView();
-        when(mContext.getResources()).thenReturn(mResources);
-        when(mResources.getStringArray(
-                R.array.allowed_packages_for_locale_confirmation_diallog)).thenReturn(
-                allowedPackage);
-        when(mResources.getString(
-                R.string.title_system_locale_addition)).thenReturn(
-                "Add %s to preferred languages?");
-        when(mResources.getString(
-                R.string.desc_system_locale_addition)).thenReturn(
-                "This lets apps and websites know you also prefer this language.");
+        when(mNotificationController.getNotificationId("ja-JP")).thenReturn(notificationId);
         when(mLocaleListEditor.getSupportedLocales()).thenReturn(supportedLocales);
     }
 
diff --git a/tests/robotests/src/com/android/settings/localepicker/LocaleNotificationDataManagerTest.java b/tests/robotests/src/com/android/settings/localepicker/LocaleNotificationDataManagerTest.java
new file mode 100644
index 0000000..99541b6
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/localepicker/LocaleNotificationDataManagerTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.spy;
+
+import android.content.Context;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Map;
+import java.util.Set;
+
+@RunWith(RobolectricTestRunner.class)
+public class LocaleNotificationDataManagerTest {
+    private Context mContext;
+    private LocaleNotificationDataManager mDataManager;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mContext = spy(RuntimeEnvironment.application);
+        mDataManager = new LocaleNotificationDataManager(mContext);
+    }
+
+    @After
+    public void tearDown() {
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void testPutGetNotificationInfo() {
+        String locale = "en-US";
+        Set<Integer> uidSet = Set.of(101);
+        NotificationInfo info = new NotificationInfo(uidSet, 1, 1, 100L, 1000);
+
+        mDataManager.putNotificationInfo(locale, info);
+        NotificationInfo expected = mDataManager.getNotificationInfo(locale);
+
+        assertThat(info.equals(expected)).isTrue();
+        assertThat(expected.getNotificationId()).isEqualTo(info.getNotificationId());
+        assertThat(expected.getDismissCount()).isEqualTo(info.getDismissCount());
+        assertThat(expected.getNotificationCount()).isEqualTo(info.getNotificationCount());
+        assertThat(expected.getUidCollection()).isEqualTo(info.getUidCollection());
+        assertThat(expected.getLastNotificationTimeMs()).isEqualTo(
+                info.getLastNotificationTimeMs());
+    }
+
+    @Test
+    public void testGetNotificationMap() {
+        String enUS = "en-US";
+        Set<Integer> uidSet1 = Set.of(101, 102);
+        NotificationInfo info1 = new NotificationInfo(uidSet1, 1, 1, 1000L, 1234);
+        String jaJP = "ja-JP";
+        Set<Integer> uidSet2 = Set.of(103, 104);
+        NotificationInfo info2 = new NotificationInfo(uidSet2, 1, 0, 2000L, 5678);
+        mDataManager.putNotificationInfo(enUS, info1);
+        mDataManager.putNotificationInfo(jaJP, info2);
+
+        Map<String, NotificationInfo> map = mDataManager.getLocaleNotificationInfoMap();
+
+        assertThat(map.size()).isEqualTo(2);
+        assertThat(mDataManager.getNotificationInfo(enUS).equals(map.get(enUS))).isTrue();
+        assertThat(mDataManager.getNotificationInfo(jaJP).equals(map.get(jaJP))).isTrue();
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/localepicker/NotificationCancelReceiverTest.java b/tests/robotests/src/com/android/settings/localepicker/NotificationCancelReceiverTest.java
new file mode 100644
index 0000000..1d34860
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/localepicker/NotificationCancelReceiverTest.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_APP_LOCALE;
+import static com.android.settings.localepicker.AppLocalePickerActivity.EXTRA_NOTIFICATION_ID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.Intent;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class NotificationCancelReceiverTest {
+    private Context mContext;
+    private NotificationCancelReceiver mReceiver;
+    @Mock
+    private NotificationController mNotificationController;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mContext = RuntimeEnvironment.application;
+        mReceiver = spy(new NotificationCancelReceiver());
+        doReturn(mNotificationController).when(mReceiver).getNotificationController(any());
+    }
+
+    @Test
+    public void testOnReceive_incrementDismissCount() {
+        String locale = "en-US";
+        int notificationId = 100;
+        Intent intent = new Intent()
+                .putExtra(EXTRA_APP_LOCALE, locale)
+                .putExtra(EXTRA_NOTIFICATION_ID, notificationId);
+        when(mNotificationController.getNotificationId(locale)).thenReturn(notificationId);
+
+        mReceiver.onReceive(mContext, intent);
+
+        verify(mNotificationController).incrementDismissCount(eq(locale));
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/localepicker/NotificationControllerTest.java b/tests/robotests/src/com/android/settings/localepicker/NotificationControllerTest.java
new file mode 100644
index 0000000..3e31c0c
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/localepicker/NotificationControllerTest.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.localepicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.os.LocaleList;
+import android.os.SystemClock;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Calendar;
+import java.util.Set;
+
+@RunWith(RobolectricTestRunner.class)
+public class NotificationControllerTest {
+    private Context mContext;
+    private LocaleNotificationDataManager mDataManager;
+    private NotificationController mNotificationController;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mContext = RuntimeEnvironment.application;
+        mNotificationController = NotificationController.getInstance(mContext);
+        mDataManager = mNotificationController.getDataManager();
+        LocaleList.setDefault(LocaleList.forLanguageTags("en-CA"));
+    }
+
+    @After
+    public void tearDown() {
+        mDataManager.clearLocaleNotificationMap();
+    }
+
+    @Test
+    public void incrementDismissCount_addOne() throws Exception {
+        String enUS = "en-US";
+        Set<Integer> uidSet = Set.of(100, 101);
+        long lastNotificationTime = Calendar.getInstance().getTimeInMillis();
+        int id = (int) SystemClock.uptimeMillis();
+        initSharedPreference(enUS, uidSet, 0, 1, lastNotificationTime, id);
+
+        mNotificationController.incrementDismissCount(enUS);
+        NotificationInfo result = mDataManager.getNotificationInfo(enUS);
+
+        assertThat(result.getDismissCount()).isEqualTo(1); // dismissCount increments
+        assertThat(result.getUidCollection()).isEqualTo(uidSet);
+        assertThat(result.getNotificationCount()).isEqualTo(1);
+        assertThat(result.getLastNotificationTimeMs()).isEqualTo(lastNotificationTime);
+        assertThat(result.getNotificationId()).isEqualTo(id);
+    }
+
+    @Test
+    public void testShouldTriggerNotification_inSystemLocale_returnFalse() throws Exception {
+        int uid = 102;
+        // As checking whether app's locales exist in system locales, both app locales and system
+        // locales have to remove the u extension first when doing the comparison. The following
+        // three locales are all in the system locale after removing the u extension so it's
+        // unnecessary to trigger a notification for the suggestion.
+        String locale1 = "en-CA";
+        String locale2 = "ar-JO-u-nu-latn";
+        String locale3 = "ar-JO";
+
+        LocaleList.setDefault(
+                LocaleList.forLanguageTags("en-CA-u-mu-fahrenhe,ar-JO-u-mu-fahrenhe-nu-latn"));
+
+        assertThat(mNotificationController.shouldTriggerNotification(uid, locale1)).isFalse();
+        assertThat(mNotificationController.shouldTriggerNotification(uid, locale2)).isFalse();
+        assertThat(mNotificationController.shouldTriggerNotification(uid, locale3)).isFalse();
+    }
+
+    @Test
+    public void testShouldTriggerNotification_noNotification_returnFalse() throws Exception {
+        int uid = 100;
+        String locale = "en-US";
+
+        boolean triggered = mNotificationController.shouldTriggerNotification(uid, locale);
+
+        assertThat(triggered).isFalse();
+    }
+
+    @Test
+    public void testShouldTriggerNotification_return1stTrue() throws Exception {
+        // Initialze proto with en-US locale. Its uid contains 100.
+        Set<Integer> uidSet = Set.of(100);
+        String locale = "en-US";
+        long lastNotificationTime = 0L;
+        int notificationId = 0;
+        initSharedPreference(locale, uidSet, 0, 1, lastNotificationTime, notificationId);
+
+        // When the second app is configured to "en-US", the notification is triggered.
+        int uid = 101;
+        boolean triggered = mNotificationController.shouldTriggerNotification(uid, locale);
+
+        assertThat(triggered).isTrue();
+    }
+
+    @Test
+    public void testShouldTriggerNotification_returnFalse_dueToOddCount() throws Exception {
+        // Initialze proto with en-US locale. Its uid contains 100,101.
+        Set<Integer> uidSet = Set.of(100, 101);
+        String locale = "en-US";
+        long lastNotificationTime = Calendar.getInstance().getTimeInMillis();
+        int id = (int) SystemClock.uptimeMillis();
+        initSharedPreference(locale, uidSet, 0, 1, lastNotificationTime, id);
+
+        // When the other app is configured to "en-US", the notification is not triggered because
+        // the app count is odd.
+        int uid = 102;
+        boolean triggered = mNotificationController.shouldTriggerNotification(uid, locale);
+
+        assertThat(triggered).isFalse();
+    }
+
+    @Test
+    public void testShouldTriggerNotification_returnFalse_dueToFrequency() throws Exception {
+        // Initialze proto with en-US locale. Its uid contains 100,101,102.
+        Set<Integer> uidSet = Set.of(100, 101, 102);
+        String locale = "en-US";
+        long lastNotificationTime = Calendar.getInstance().getTimeInMillis();
+        int id = (int) SystemClock.uptimeMillis();
+        initSharedPreference(locale, uidSet, 0, 1, lastNotificationTime, id);
+
+        // When the other app is configured to "en-US", the notification is not triggered because it
+        // is too frequent.
+        int uid = 103;
+        boolean triggered = mNotificationController.shouldTriggerNotification(uid, locale);
+
+        assertThat(triggered).isFalse();
+    }
+
+    @Test
+    public void testShouldTriggerNotification_return2ndTrue() throws Exception {
+        // Initialze proto with en-US locale. Its uid contains 100,101,102,103,104.
+        Set<Integer> uidSet = Set.of(100, 101, 102, 103, 104);
+        String locale = "en-US";
+        int id = (int) SystemClock.uptimeMillis();
+        Calendar time = Calendar.getInstance();
+        time.add(Calendar.MINUTE, 86400 * 8 * (-1));
+        long lastNotificationTime = time.getTimeInMillis();
+        initSharedPreference(locale, uidSet, 0, 1, lastNotificationTime, id);
+
+        // When the other app is configured to "en-US", the notification is triggered.
+        int uid = 105;
+        boolean triggered = mNotificationController.shouldTriggerNotification(uid, locale);
+
+        assertThat(triggered).isTrue();
+    }
+
+    private void initSharedPreference(String locale, Set<Integer> uidCollection, int dismissCount,
+            int notificationCount, long lastNotificationTime, int notificationId)
+            throws Exception {
+        NotificationInfo info = new NotificationInfo(uidCollection, notificationCount, dismissCount,
+                lastNotificationTime, notificationId);
+        mDataManager.putNotificationInfo(locale, info);
+    }
+}