• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /**
2  * Copyright (C) 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.localepicker;
18 
19 import android.app.Activity;
20 import android.app.settings.SettingsEnums;
21 import android.content.Context;
22 import android.content.pm.ApplicationInfo;
23 import android.content.pm.PackageManager;
24 import android.os.Bundle;
25 import android.os.LocaleList;
26 import android.text.TextUtils;
27 import android.util.FeatureFlagUtils;
28 import android.util.Log;
29 import android.view.LayoutInflater;
30 import android.view.Menu;
31 import android.view.MenuInflater;
32 import android.view.MenuItem;
33 import android.view.View;
34 import android.view.ViewGroup;
35 import android.widget.Filter;
36 import android.widget.SearchView;
37 
38 import androidx.annotation.NonNull;
39 import androidx.annotation.Nullable;
40 import androidx.core.view.ViewCompat;
41 import androidx.preference.Preference;
42 import androidx.preference.PreferenceCategory;
43 import androidx.preference.PreferenceScreen;
44 import androidx.recyclerview.widget.RecyclerView;
45 
46 import com.android.internal.app.AppLocaleCollector;
47 import com.android.internal.app.LocaleHelper;
48 import com.android.internal.app.LocaleStore;
49 import com.android.settings.R;
50 import com.android.settings.Utils;
51 import com.android.settings.applications.AppLocaleUtil;
52 import com.android.settings.dashboard.DashboardFragment;
53 import com.android.settings.search.BaseSearchIndexProvider;
54 import com.android.settingslib.core.AbstractPreferenceController;
55 import com.android.settingslib.core.lifecycle.Lifecycle;
56 
57 import com.google.android.material.appbar.AppBarLayout;
58 
59 import java.util.ArrayList;
60 import java.util.Arrays;
61 import java.util.List;
62 import java.util.Locale;
63 import java.util.Set;
64 
65 /**
66  * A locale picker fragment to show app languages.
67  *
68  * <p>It shows suggestions at the top, then the rest of the locales.
69  * Allows the user to search for locales using both their native name and their name in the
70  * default locale.</p>
71  */
72 public class AppLocalePickerFragment extends DashboardFragment implements
73         SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
74     public static final String ARG_PACKAGE_NAME = "package";
75     public static final String ARG_PACKAGE_UID = "uid";
76 
77     private static final String TAG = "AppLocalePickerFragment";
78     private static final String EXTRA_EXPAND_SEARCH_VIEW = "expand_search_view";
79     private static final String EXTRA_SEARCH_VIEW_QUERY = "search_view_query";
80     private static final String KEY_PREFERENCE_APP_LOCALE_LIST = "app_locale_list";
81     private static final String KEY_PREFERENCE_APP_LOCALE_SUGGESTED_LIST =
82             "app_locale_suggested_list";
83     private static final String KEY_PREFERENCE_APP_DISCLAIMER = "app_locale_disclaimer";
84     private static final String KEY_PREFERENCE_APP_INTRO = "app_intro";
85     private static final String KEY_PREFERENCE_APP_DESCRIPTION = "app_locale_description";
86 
87     @Nullable
88     private SearchView mSearchView = null;
89     @Nullable
90     private SearchFilter mSearchFilter = null;
91     @Nullable
92     private List<LocaleStore.LocaleInfo> mLocaleOptions;
93     @Nullable
94     private List<LocaleStore.LocaleInfo> mOriginalLocaleInfos;
95     @Nullable
96     private LocaleStore.LocaleInfo mLocaleInfo;
97     @Nullable
98     private AppLocaleAllListPreferenceController mAppLocaleAllListPreferenceController;
99     @Nullable
100     private AppLocaleSuggestedListPreferenceController mSuggestedListPreferenceController;
101     private AppBarLayout mAppBarLayout;
102     private RecyclerView mRecyclerView;
103     private PreferenceScreen mPreferenceScreen;
104     private boolean mExpandSearch;
105     private int mUid;
106     private Activity mActivity;
107     @SuppressWarnings("NullAway")
108     private String mPackageName;
109     @Nullable
110     private ApplicationInfo mApplicationInfo;
111     private boolean mIsNumberingMode;
112     private CharSequence mPreviousSearch = null;
113 
114     @Override
onCreate(@onNull Bundle icicle)115     public void onCreate(@NonNull Bundle icicle) {
116         super.onCreate(icicle);
117         mActivity = getActivity();
118 
119         if (mActivity.isFinishing()) {
120             return;
121         }
122 
123         if (TextUtils.isEmpty(mPackageName)) {
124             Log.d(TAG, "There is no package name.");
125             return;
126         }
127 
128         if (!canDisplayLocaleUi()) {
129             Log.w(TAG, "Not allow to display Locale Settings UI.");
130             return;
131         }
132 
133         mPreferenceScreen = getPreferenceScreen();
134         setHasOptionsMenu(true);
135         mApplicationInfo = getApplicationInfo(mPackageName, mUid);
136         setupDisclaimerPreference();
137         setupIntroPreference();
138         setupDescriptionPreference();
139         mExpandSearch = mActivity.getIntent().getBooleanExtra(EXTRA_EXPAND_SEARCH_VIEW, false);
140         if (icicle != null) {
141             mExpandSearch = icicle.getBoolean(EXTRA_EXPAND_SEARCH_VIEW);
142             mPreviousSearch = icicle.getCharSequence(EXTRA_SEARCH_VIEW_QUERY);
143         }
144 
145         AppLocaleCollector appLocaleCollector = new AppLocaleCollector(mActivity, mPackageName);
146         Set<LocaleStore.LocaleInfo> localeList = appLocaleCollector.getSupportedLocaleList(null,
147                 false, false);
148         mLocaleOptions = new ArrayList<>(localeList.size());
149     }
150 
151     @Override
onCreateView(@onNull LayoutInflater inflater, @NonNull ViewGroup container, @NonNull Bundle savedInstanceState)152     public @NonNull View onCreateView(@NonNull LayoutInflater inflater,
153             @NonNull ViewGroup container, @NonNull Bundle savedInstanceState) {
154         mAppBarLayout = mActivity.findViewById(R.id.app_bar);
155         return super.onCreateView(inflater, container, savedInstanceState);
156     }
157 
158     @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)159     public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
160         super.onViewCreated(view, savedInstanceState);
161         mRecyclerView = view.findViewById(R.id.recycler_view);
162     }
163 
164     @Override
onSaveInstanceState(@onNull Bundle outState)165     public void onSaveInstanceState(@NonNull Bundle outState) {
166         super.onSaveInstanceState(outState);
167         if (mSearchView != null) {
168             outState.putBoolean(EXTRA_EXPAND_SEARCH_VIEW, !mSearchView.isIconified());
169             outState.putCharSequence(EXTRA_SEARCH_VIEW_QUERY, mSearchView.getQuery());
170         }
171     }
172 
173     @Override
onCreateOptionsMenu(@onNull Menu menu, @NonNull MenuInflater inflater)174     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
175         super.onCreateOptionsMenu(menu, inflater);
176         inflater.inflate(R.menu.language_selection_list, menu);
177         final MenuItem searchMenuItem = menu.findItem(R.id.locale_search_menu);
178         if (searchMenuItem != null) {
179             searchMenuItem.setOnActionExpandListener(this);
180             mSearchView = (SearchView) searchMenuItem.getActionView();
181             mSearchView.setQueryHint(
182                     mActivity.getResources().getText(R.string.search_language_hint));
183             mSearchView.setOnQueryTextListener(this);
184             mSearchView.setMaxWidth(Integer.MAX_VALUE);
185             if (mExpandSearch) {
186                 searchMenuItem.expandActionView();
187             }
188             // Restore previous search status
189             if (!TextUtils.isEmpty(mPreviousSearch)) {
190                 searchMenuItem.expandActionView();
191                 mSearchView.setIconified(false);
192                 mSearchView.setActivated(true);
193                 mSearchView.setQuery(mPreviousSearch, true /* submit */);
194             } else {
195                 mSearchView.setQuery(null, false /* submit */);
196             }
197         }
198     }
199 
setupDisclaimerPreference()200     private void setupDisclaimerPreference() {
201         final Preference pref = mPreferenceScreen.findPreference(KEY_PREFERENCE_APP_DISCLAIMER);
202         boolean shouldShowPref = pref != null && FeatureFlagUtils.isEnabled(
203                 mActivity, FeatureFlagUtils.SETTINGS_APP_LOCALE_OPT_IN_ENABLED);
204         pref.setVisible(shouldShowPref);
205     }
206 
setupIntroPreference()207     private void setupIntroPreference() {
208         final Preference pref = mPreferenceScreen.findPreference(KEY_PREFERENCE_APP_INTRO);
209         if (pref != null && mApplicationInfo != null) {
210             pref.setIcon(Utils.getBadgedIcon(mActivity, mApplicationInfo));
211             pref.setTitle(mApplicationInfo.loadLabel(mActivity.getPackageManager()));
212         }
213     }
214 
setupDescriptionPreference()215     private void setupDescriptionPreference() {
216         final Preference pref = mPreferenceScreen.findPreference(
217                 KEY_PREFERENCE_APP_DESCRIPTION);
218         int res = getAppDescription();
219         if (pref != null && res != -1) {
220             pref.setVisible(true);
221             pref.setTitle(mActivity.getString(res));
222         } else {
223             pref.setVisible(false);
224         }
225     }
226 
getAppDescription()227     private int getAppDescription() {
228         LocaleList packageLocaleList = AppLocaleUtil.getPackageLocales(mActivity, mPackageName);
229         String[] assetLocaleList = AppLocaleUtil.getAssetLocales(mActivity, mPackageName);
230         // TODO add appended url string, "Learn more", to these both sentences.
231         if ((packageLocaleList != null && packageLocaleList.isEmpty())
232                 || (packageLocaleList == null && assetLocaleList.length == 0)) {
233             return R.string.desc_no_available_supported_locale;
234         }
235         return -1;
236     }
237 
getApplicationInfo(String packageName, int userId)238     private @Nullable ApplicationInfo getApplicationInfo(String packageName, int userId) {
239         ApplicationInfo applicationInfo;
240         try {
241             applicationInfo = mActivity.getPackageManager()
242                     .getApplicationInfoAsUser(packageName, /* flags= */ 0, userId);
243             return applicationInfo;
244         } catch (PackageManager.NameNotFoundException e) {
245             Log.w(TAG, "Application info not found for: " + packageName);
246             return null;
247         }
248     }
249 
canDisplayLocaleUi()250     private boolean canDisplayLocaleUi() {
251         try {
252             PackageManager packageManager = getPackageManager();
253             return AppLocaleUtil.canDisplayLocaleUi(mActivity,
254                     packageManager.getApplicationInfo(mPackageName, 0),
255                     packageManager.queryIntentActivities(AppLocaleUtil.LAUNCHER_ENTRY_INTENT,
256                             PackageManager.GET_META_DATA));
257         } catch (PackageManager.NameNotFoundException e) {
258             Log.e(TAG, "Unable to find info for package: " + mPackageName);
259         }
260 
261         return false;
262     }
263 
filterSearch(@ullable String query)264     private void filterSearch(@Nullable String query) {
265         if (mAppLocaleAllListPreferenceController == null) {
266             Log.d(TAG, "filterSearch(), can not get preference.");
267             return;
268         }
269 
270         if (mSearchFilter == null) {
271             mSearchFilter = new SearchFilter();
272         }
273 
274         mOriginalLocaleInfos = mAppLocaleAllListPreferenceController.getSupportedLocaleList();
275         // If we haven't load apps list completely, don't filter anything.
276         if (mOriginalLocaleInfos == null) {
277             Log.w(TAG, "Locales haven't loaded completely yet, so nothing can be filtered");
278             return;
279         }
280         mSearchFilter.filter(query);
281     }
282 
283     private class SearchFilter extends Filter {
284 
285         @Override
performFiltering(CharSequence prefix)286         protected FilterResults performFiltering(CharSequence prefix) {
287             FilterResults results = new FilterResults();
288 
289             if (mOriginalLocaleInfos == null) {
290                 mOriginalLocaleInfos = new ArrayList<>(mLocaleOptions);
291             }
292 
293             if (TextUtils.isEmpty(prefix)) {
294                 results.values = mOriginalLocaleInfos;
295                 results.count = mOriginalLocaleInfos.size();
296             } else {
297                 // TODO: decide if we should use the string's locale
298                 Locale locale = Locale.getDefault();
299                 String prefixString = LocaleHelper.normalizeForSearch(prefix.toString(), locale);
300 
301                 final int count = mOriginalLocaleInfos.size();
302                 final ArrayList<LocaleStore.LocaleInfo> newValues = new ArrayList<>();
303 
304                 for (int i = 0; i < count; i++) {
305                     final LocaleStore.LocaleInfo value = mOriginalLocaleInfos.get(i);
306                     final String nameToCheck = LocaleHelper.normalizeForSearch(
307                             value.getFullNameInUiLanguage(), locale);
308                     final String nativeNameToCheck = LocaleHelper.normalizeForSearch(
309                             value.getFullNameNative(), locale);
310                     if ((wordMatches(nativeNameToCheck, prefixString)
311                             || wordMatches(nameToCheck, prefixString)) && !newValues.contains(
312                             value)) {
313                         newValues.add(value);
314                     }
315                 }
316 
317                 results.values = newValues;
318                 results.count = newValues.size();
319             }
320 
321             return results;
322         }
323 
324         @Override
publishResults(CharSequence constraint, FilterResults results)325         protected void publishResults(CharSequence constraint, FilterResults results) {
326             if (mAppLocaleAllListPreferenceController == null
327                     || mSuggestedListPreferenceController == null) {
328                 Log.d(TAG, "publishResults(), can not get preference.");
329                 return;
330             }
331 
332             mLocaleOptions = (ArrayList<LocaleStore.LocaleInfo>) results.values;
333             // Need to scroll to first preference when searching.
334             if (mRecyclerView != null) {
335                 mRecyclerView.post(() -> mRecyclerView.scrollToPosition(0));
336             }
337 
338             mAppLocaleAllListPreferenceController.onSearchListChanged(mLocaleOptions, null);
339             mSuggestedListPreferenceController.onSearchListChanged(mLocaleOptions, null);
340         }
341 
342         // TODO: decide if this is enough, or we want to use a BreakIterator...
wordMatches(String valueText, String prefixString)343         private boolean wordMatches(String valueText, String prefixString) {
344             if (valueText == null) {
345                 return false;
346             }
347 
348             // First match against the whole, non-split value
349             if (valueText.startsWith(prefixString)) {
350                 return true;
351             }
352 
353             return Arrays.stream(valueText.split(" "))
354                     .anyMatch(word -> word.startsWith(prefixString));
355         }
356     }
357 
358     @Override
onMenuItemActionExpand(@onNull MenuItem item)359     public boolean onMenuItemActionExpand(@NonNull MenuItem item) {
360         // To prevent a large space on tool bar.
361         mAppBarLayout.setExpanded(false /*expanded*/, false /*animate*/);
362         // To prevent user can expand the collapsing tool bar view.
363         ViewCompat.setNestedScrollingEnabled(mRecyclerView, false);
364         return true;
365     }
366 
367     @Override
onMenuItemActionCollapse(@onNull MenuItem item)368     public boolean onMenuItemActionCollapse(@NonNull MenuItem item) {
369         // We keep the collapsed status after user cancel the search function.
370         mAppBarLayout.setExpanded(false /*expanded*/, false /*animate*/);
371         ViewCompat.setNestedScrollingEnabled(mRecyclerView, true);
372         return true;
373     }
374 
375     @Override
onQueryTextSubmit(@ullable String query)376     public boolean onQueryTextSubmit(@Nullable String query) {
377         return false;
378     }
379 
380     @Override
onQueryTextChange(@ullable String newText)381     public boolean onQueryTextChange(@Nullable String newText) {
382         filterSearch(newText);
383         return false;
384     }
385 
386     @Override
getLogTag()387     protected String getLogTag() {
388         return TAG;
389     }
390 
391     @Override
getMetricsCategory()392     public int getMetricsCategory() {
393         return SettingsEnums.APPS_LOCALE_LIST;
394     }
395 
396     @Override
getPreferenceScreenResId()397     protected int getPreferenceScreenResId() {
398         return R.xml.app_language_picker;
399     }
400 
401     @Override
createPreferenceControllers(Context context)402     protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
403         return buildPreferenceControllers(context);
404     }
405 
buildPreferenceControllers( @onNull Context context)406     private List<AbstractPreferenceController> buildPreferenceControllers(
407             @NonNull Context context) {
408         Bundle args = getArguments();
409         mPackageName = args.getString(ARG_PACKAGE_NAME);
410         mUid = args.getInt(ARG_PACKAGE_UID);
411         mLocaleInfo = (LocaleStore.LocaleInfo) args.getSerializable(
412                 RegionAndNumberingSystemPickerFragment.EXTRA_TARGET_LOCALE);
413         mIsNumberingMode = args.getBoolean(
414                 RegionAndNumberingSystemPickerFragment.EXTRA_IS_NUMBERING_SYSTEM);
415 
416         AppLocaleCollector appLocaleCollector = new AppLocaleCollector(context, mPackageName);
417         mSuggestedListPreferenceController =
418                 new AppLocaleSuggestedListPreferenceController(context,
419                         KEY_PREFERENCE_APP_LOCALE_SUGGESTED_LIST, mPackageName, mIsNumberingMode,
420                         mLocaleInfo, getActivity(), appLocaleCollector);
421         mAppLocaleAllListPreferenceController = new AppLocaleAllListPreferenceController(
422                 context, KEY_PREFERENCE_APP_LOCALE_LIST, mPackageName, mIsNumberingMode,
423                 mLocaleInfo, getActivity(), appLocaleCollector);
424         final List<AbstractPreferenceController> controllers = new ArrayList<>();
425         controllers.add(mSuggestedListPreferenceController);
426         controllers.add(mAppLocaleAllListPreferenceController);
427 
428         return controllers;
429     }
430 
431     public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
432             new BaseSearchIndexProvider(R.xml.app_language_picker);
433 }
434