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