diff --git a/res/drawable/ic_settings_globe.xml b/res/drawable/ic_settings_globe.xml new file mode 100644 index 00000000000..9834df6adba --- /dev/null +++ b/res/drawable/ic_settings_globe.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 23939822b4a..6b5731b14c8 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -527,6 +527,10 @@ The region you choose affects how your phone displays time, dates, temperature, and more More language settings + + Change region to %s ? + + Your device will keep %s as a system language diff --git a/src/com/android/settings/localepicker/AppLocaleSuggestedListPreferenceController.java b/src/com/android/settings/localepicker/AppLocaleSuggestedListPreferenceController.java new file mode 100644 index 00000000000..3fe95508a54 --- /dev/null +++ b/src/com/android/settings/localepicker/AppLocaleSuggestedListPreferenceController.java @@ -0,0 +1,203 @@ +/** + * Copyright (C) 2025 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.applications.manageapplications.ManageApplications.LIST_TYPE_APPS_LOCALE; + +import android.app.Activity; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.util.ArrayMap; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import com.android.internal.app.AppLocaleCollector; +import com.android.internal.app.LocaleStore; +import com.android.settings.R; +import com.android.settings.applications.manageapplications.ManageApplicationsUtil; +import com.android.settings.core.BasePreferenceController; +import com.android.settingslib.widget.SelectorWithWidgetPreference; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** A controller for handling suggested locale of app. */ +public class AppLocaleSuggestedListPreferenceController extends + BasePreferenceController implements LocaleListSearchCallback { + private static final String TAG = "AppLocaleSuggestedListPreferenceController"; + private static final String KEY_PREFERENCE_CATEGORY_APP_LANGUAGE_SUGGESTED = + "app_language_suggested_category"; + private static final String KEY_PREFERENCE_APP_LOCALE_SUGGESTED_LIST = + "app_locale_suggested_list"; + private static final String KEY_PREFERENCE_CATEGORY_ADD_A_LANGUAGE_SUGGESTED = + "system_language_suggested_category"; + + @SuppressWarnings("NullAway") + private PreferenceCategory mPreferenceCategory; + private Set mLocaleList; + private List mLocaleOptions; + private Map mSuggestedPreferences; + private boolean mIsCountryMode; + @Nullable private LocaleStore.LocaleInfo mParentLocale; + private AppLocaleCollector mAppLocaleCollector; + @SuppressWarnings("NullAway") + private String mPackageName; + private boolean mIsNumberingSystemMode; + + @SuppressWarnings("NullAway") + public AppLocaleSuggestedListPreferenceController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + } + + @SuppressWarnings("NullAway") + public AppLocaleSuggestedListPreferenceController(@NonNull Context context, + @NonNull String preferenceKey, @Nullable String packageName, + boolean isNumberingSystemMode, @NonNull LocaleStore.LocaleInfo parentLocale) { + super(context, preferenceKey); + mPackageName = packageName; + mIsNumberingSystemMode = isNumberingSystemMode; + mParentLocale = parentLocale; + mIsCountryMode = mParentLocale != null; + } + + @Override + public void displayPreference(@NonNull PreferenceScreen screen) { + super.displayPreference(screen); + mPreferenceCategory = screen.findPreference( + (mIsNumberingSystemMode || mIsCountryMode) + ? KEY_PREFERENCE_CATEGORY_ADD_A_LANGUAGE_SUGGESTED + : KEY_PREFERENCE_CATEGORY_APP_LANGUAGE_SUGGESTED); + + mAppLocaleCollector = new AppLocaleCollector(mContext, mPackageName); + mSuggestedPreferences = new ArrayMap<>(); + mLocaleOptions = new ArrayList<>(); + updatePreferences(); + } + + private void updatePreferences() { + if (mPreferenceCategory == null) { + Log.d(TAG, "updatePreferences, mPreferenceCategory is null"); + return; + } + + List result = LocaleUtils.getSortedLocaleList( + getSuggestedLocaleList(), mIsCountryMode); + final Map existingSuggestedPreferences = mSuggestedPreferences; + mSuggestedPreferences = new ArrayMap<>(); + setupSuggestedPreference(result, existingSuggestedPreferences); + for (Preference pref : existingSuggestedPreferences.values()) { + mPreferenceCategory.removePreference(pref); + } + } + + @Override + public void onSearchListChanged(@NonNull List newList, + @Nullable CharSequence prefix) { + if (mPreferenceCategory == null) { + Log.d(TAG, "onSearchListChanged, mPreferenceCategory is null"); + return; + } + + mPreferenceCategory.removeAll(); + final Map existingSuggestedPreferences = mSuggestedPreferences; + List sortedList = getSuggestedLocaleList(); + newList = LocaleUtils.getSortedLocaleFromSearchList(newList, sortedList, mIsCountryMode); + setupSuggestedPreference(newList, existingSuggestedPreferences); + } + + private void setupSuggestedPreference(List localeInfoList, + Map existingSuggestedPreferences) { + for (LocaleStore.LocaleInfo locale : localeInfoList) { + if (mIsNumberingSystemMode || mIsCountryMode) { + Preference pref = existingSuggestedPreferences.remove(locale.getId()); + if (pref == null) { + pref = new Preference(mContext); + setupPreference(pref, locale); + mPreferenceCategory.addPreference(pref); + } + } else { + SelectorWithWidgetPreference pref = + (SelectorWithWidgetPreference) existingSuggestedPreferences.remove( + locale.getId()); + if (pref == null) { + pref = new SelectorWithWidgetPreference(mContext); + setupPreference(pref, locale); + mPreferenceCategory.addPreference(pref); + } + } + } + Log.d(TAG, "setupSuggestedPreference, mPreferenceCategory setVisible" + + (mPreferenceCategory.getPreferenceCount() > 0)); + mPreferenceCategory.setVisible(mPreferenceCategory.getPreferenceCount() > 0); + } + + private void setupPreference(Preference pref, LocaleStore.LocaleInfo locale) { + String localeName = mIsCountryMode ? locale.getFullCountryNameNative() + : locale.getFullNameNative(); + if (pref instanceof SelectorWithWidgetPreference) { + ((SelectorWithWidgetPreference) pref).setChecked(locale.isAppCurrentLocale()); + } + pref.setTitle(locale.isSystemLocale() + ? mContext.getString(R.string.preference_of_system_locale_summary) + : localeName); + pref.setKey(locale.toString()); + pref.setOnPreferenceClickListener(clickedPref -> { + LocaleUtils.onLocaleSelected(mContext, locale, mPackageName); + ((Activity) mContext).finish(); + return true; + }); + mSuggestedPreferences.put(locale.getId(), pref); + } + + @Override + public int getAvailabilityStatus() { + return AVAILABLE; + } + + protected List getSuggestedLocaleList() { + setupLocaleList(); + if (mLocaleList != null && !mLocaleList.isEmpty()) { + mLocaleOptions.addAll( + mLocaleList.stream().filter(localeInfo -> (localeInfo.isSuggested())).collect( + Collectors.toList())); + } else { + Log.d(TAG, "Can not get suggested locales because the locale list is null or empty."); + } + return mLocaleOptions; + } + + private void setupLocaleList() { + mLocaleList = mAppLocaleCollector.getSupportedLocaleList(mParentLocale, + false, mIsCountryMode); + mLocaleOptions.clear(); + } + + @Override + public @NonNull String getPreferenceKey() { + return KEY_PREFERENCE_APP_LOCALE_SUGGESTED_LIST; + } +} diff --git a/src/com/android/settings/localepicker/LocaleUtils.java b/src/com/android/settings/localepicker/LocaleUtils.java index a84d0beb7a8..b6502180aa0 100644 --- a/src/com/android/settings/localepicker/LocaleUtils.java +++ b/src/com/android/settings/localepicker/LocaleUtils.java @@ -16,16 +16,53 @@ package com.android.settings.localepicker; +import static com.android.settings.flags.Flags.localeNotificationEnabled; +import static com.android.settings.localepicker.LocaleListEditor.EXTRA_RESULT_LOCALE; +import static com.android.settings.localepicker.RegionAndNumberingSystemPickerFragment.EXTRA_IS_NUMBERING_SYSTEM; +import static com.android.settings.localepicker.RegionAndNumberingSystemPickerFragment.EXTRA_TARGET_LOCALE; + +import android.app.Dialog; +import android.app.LocaleManager; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; import android.os.LocaleList; +import android.os.SystemClock; +import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import com.android.internal.app.LocaleHelper; +import com.android.internal.app.LocaleStore; +import com.android.settings.R; +import com.android.settings.overlay.FeatureFactory; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; /** * A locale utility class. */ public class LocaleUtils { + private static final String TAG = "LocaleUtils"; + private static final String CHANNEL_ID_SUGGESTION = "suggestion"; + private static final String CHANNEL_ID_SUGGESTION_TO_USER = "Locale suggestion"; + private static final String EXTRA_APP_LOCALE = "app_locale"; + private static final String EXTRA_NOTIFICATION_ID = "notification_id"; + private static final int SIM_LOCALE = 1 << 0; + private static final int SYSTEM_LOCALE = 1 << 1; + private static final int APP_LOCALE = 1 << 2; + private static final int IME_LOCALE = 1 << 3; + /** * 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 @@ -50,4 +87,191 @@ public class LocaleUtils { } return false; } + + /** + * Logs the locale, sets the default locale for the app then broadcasts it. + * + * @param context Context + * @param localeInfo locale info + */ + public static void onLocaleSelected(@NonNull Context context, + @NonNull LocaleStore.LocaleInfo localeInfo, + @NonNull String packageName) { + if (localeInfo.getLocale() == null || localeInfo.isSystemLocale()) { + setAppDefaultLocale(context, "", packageName); + } else { + logLocaleSource(context, localeInfo); + setAppDefaultLocale(context, localeInfo.getLocale().toLanguageTag(), + packageName); + broadcastAppLocaleChange(context, localeInfo, packageName); + } + } + + private static void logLocaleSource(Context context, LocaleStore.LocaleInfo localeInfo) { + if (!localeInfo.isSuggested() || localeInfo.isAppCurrentLocale()) { + return; + } + + int localeSource = 0; + if (hasSuggestionType(localeInfo, + LocaleStore.LocaleInfo.SUGGESTION_TYPE_SYSTEM_AVAILABLE_LANGUAGE)) { + localeSource |= SYSTEM_LOCALE; + } + if (hasSuggestionType(localeInfo, + LocaleStore.LocaleInfo.SUGGESTION_TYPE_OTHER_APP_LANGUAGE)) { + localeSource |= APP_LOCALE; + } + if (hasSuggestionType(localeInfo, LocaleStore.LocaleInfo.SUGGESTION_TYPE_IME_LANGUAGE)) { + localeSource |= IME_LOCALE; + } + if (hasSuggestionType(localeInfo, LocaleStore.LocaleInfo.SUGGESTION_TYPE_SIM)) { + localeSource |= SIM_LOCALE; + } + MetricsFeatureProvider metricsFeatureProvider = + FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); + metricsFeatureProvider.action(context, + SettingsEnums.ACTION_CHANGE_APP_LANGUAGE_FROM_SUGGESTED, localeSource); + } + + private static boolean hasSuggestionType(LocaleStore.LocaleInfo localeInfo, + int suggestionType) { + return localeInfo.isSuggestionOfType(suggestionType); + } + + private static void setAppDefaultLocale(Context context, String languageTag, + String packageName) { + LocaleManager localeManager = context.getSystemService(LocaleManager.class); + if (localeManager == null) { + Log.w(TAG, "LocaleManager is null, cannot set default app locale"); + return; + } + localeManager.setApplicationLocales(packageName, + LocaleList.forLanguageTags(languageTag)); + } + + private static void broadcastAppLocaleChange(Context context, LocaleStore.LocaleInfo localeInfo, + String packageName) { + if (!localeNotificationEnabled()) { + Log.w(TAG, "Locale notification is not enabled"); + return; + } + if (localeInfo.isAppCurrentLocale()) { + return; + } + try { + NotificationController notificationController = NotificationController.getInstance( + context); + String localeTag = localeInfo.getLocale().toLanguageTag(); + int uid = context.getPackageManager().getApplicationInfo(packageName, + PackageManager.GET_META_DATA).uid; + boolean launchNotification = notificationController.shouldTriggerNotification( + uid, localeTag); + if (launchNotification) { + triggerNotification( + context, + notificationController.getNotificationId(localeTag), + context.getString(R.string.title_system_locale_addition, + localeInfo.getFullNameNative()), + context.getString(R.string.desc_system_locale_addition), + localeTag); + MetricsFeatureProvider metricsFeatureProvider = + FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); + metricsFeatureProvider.action(context, + SettingsEnums.ACTION_NOTIFICATION_FOR_SYSTEM_LOCALE); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Unable to find info for package: " + packageName); + } + } + + private static void triggerNotification( + Context context, + int notificationId, + String title, + String description, + String localeTag) { + NotificationManager notificationManager = context.getSystemService( + NotificationManager.class); + final boolean channelExist = + notificationManager.getNotificationChannel(CHANNEL_ID_SUGGESTION) != null; + + // 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); + } + final NotificationCompat.Builder builder = + new NotificationCompat.Builder(context, CHANNEL_ID_SUGGESTION) + .setSmallIcon(R.drawable.ic_settings_language) + .setAutoCancel(true) + .setContentTitle(title) + .setContentText(description) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent( + createPendingIntent(context, localeTag, notificationId, false)) + .setDeleteIntent( + createPendingIntent(context, localeTag, notificationId, true)); + notificationManager.notify(notificationId, builder.build()); + } + + private static PendingIntent createPendingIntent(Context context, String locale, + int notificationId, + boolean isDeleteIntent) { + Intent intent = isDeleteIntent + ? new Intent(context, NotificationCancelReceiver.class) + : new Intent(context, NotificationActionActivity.class) + .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(context, elapsedTime, intent, flag) + : PendingIntent.getActivity(context, elapsedTime, intent, flag); + } + + /** + * Sort the locale's list. + * + * @param localeInfos list of locale Infos + * @param isCountryMode Whether the locale page is in country mode or not. + * @return localeInfos list of locale Infos + */ + public static @NonNull List getSortedLocaleList( + @NonNull List localeInfos, boolean isCountryMode) { + final Locale sortingLocale = Locale.getDefault(); + final LocaleHelper.LocaleInfoComparator comp = new LocaleHelper.LocaleInfoComparator( + sortingLocale, isCountryMode); + Collections.sort(localeInfos, comp); + return localeInfos; + } + + /** + * Sort the locale's list by keywords in search. + * + * @param searchList locale Infos in search bar + * @param localeList list of locale Infos + * @param isCountryMode Whether the locale page is in country mode or not. + * @return localeInfos list of locale Infos + */ + public static @NonNull List getSortedLocaleFromSearchList( + @NonNull List searchList, + @NonNull List localeList, + boolean isCountryMode) { + List searchItem = localeList.stream() + .filter(suggested -> searchList.stream() + .anyMatch(option -> option.getLocale() != null + && option.getLocale().getLanguage().equals( + suggested.getLocale().getLanguage()))) + .distinct() + .collect(Collectors.toList()); + return getSortedLocaleList(searchItem, isCountryMode); + } }