1 /*
2  * Copyright (C) 2016 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.internal.app;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.content.Context;
22 import android.content.res.Configuration;
23 import android.text.TextUtils;
24 import android.view.LayoutInflater;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.widget.BaseAdapter;
28 import android.widget.Filter;
29 import android.widget.Filterable;
30 import android.widget.LinearLayout;
31 import android.widget.TextView;
32 
33 import com.android.internal.R;
34 
35 import java.util.ArrayList;
36 import java.util.Collections;
37 import java.util.Locale;
38 import java.util.Set;
39 
40 /**
41  * This adapter wraps around a regular ListAdapter for LocaleInfo, and creates 2 sections.
42  *
43  * <p>The first section contains "suggested" languages (usually including a region),
44  * the second section contains all the languages within the original adapter.
45  * The "others" might still include languages that appear in the "suggested" section.</p>
46  *
47  * <p>Example: if we show "German Switzerland" as "suggested" (based on SIM, let's say),
48  * then "German" will still show in the "others" section, clicking on it will only show the
49  * countries for all the other German locales, but not Switzerland
50  * (Austria, Belgium, Germany, Liechtenstein, Luxembourg)</p>
51  */
52 public class SuggestedLocaleAdapter extends BaseAdapter implements Filterable {
53     protected static final int TYPE_HEADER_SUGGESTED = 0;
54     protected static final int TYPE_HEADER_ALL_OTHERS = 1;
55     protected static final int TYPE_LOCALE = 2;
56     protected static final int TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER = 3;
57     protected static final int TYPE_CURRENT_LOCALE = 4;
58     protected static final int MIN_REGIONS_FOR_SUGGESTIONS = 6;
59     protected static final int APP_LANGUAGE_PICKER_TYPE_COUNT = 5;
60     protected static final int SYSTEM_LANGUAGE_TYPE_COUNT = 3;
61     protected static final int SYSTEM_LANGUAGE_WITHOUT_HEADER_TYPE_COUNT = 1;
62 
63     protected ArrayList<LocaleStore.LocaleInfo> mLocaleOptions;
64     protected ArrayList<LocaleStore.LocaleInfo> mOriginalLocaleOptions;
65     protected int mSuggestionCount;
66     protected final boolean mCountryMode;
67     protected boolean mIsNumberingMode;
68     protected LayoutInflater mInflater;
69 
70     protected Locale mDisplayLocale = null;
71     // used to potentially cache a modified Context that uses mDisplayLocale
72     protected Context mContextOverride = null;
73     private boolean mHasSpecificAppPackageName;
74 
SuggestedLocaleAdapter(Set<LocaleStore.LocaleInfo> localeOptions, boolean countryMode)75     public SuggestedLocaleAdapter(Set<LocaleStore.LocaleInfo> localeOptions, boolean countryMode) {
76         this(localeOptions, countryMode, false);
77     }
78 
SuggestedLocaleAdapter(Set<LocaleStore.LocaleInfo> localeOptions, boolean countryMode, boolean hasSpecificAppPackageName)79     public SuggestedLocaleAdapter(Set<LocaleStore.LocaleInfo> localeOptions, boolean countryMode,
80             boolean hasSpecificAppPackageName) {
81         mCountryMode = countryMode;
82         mLocaleOptions = new ArrayList<>(localeOptions.size());
83         mHasSpecificAppPackageName = hasSpecificAppPackageName;
84 
85         for (LocaleStore.LocaleInfo li : localeOptions) {
86             if (li.isSuggested()) {
87                 mSuggestionCount++;
88             }
89             mLocaleOptions.add(li);
90         }
91     }
92 
setNumberingSystemMode(boolean isNumberSystemMode)93     public void setNumberingSystemMode(boolean isNumberSystemMode) {
94         mIsNumberingMode = isNumberSystemMode;
95     }
96 
getIsForNumberingSystem()97     public boolean getIsForNumberingSystem() {
98         return mIsNumberingMode;
99     }
100 
101     @Override
areAllItemsEnabled()102     public boolean areAllItemsEnabled() {
103         return false;
104     }
105 
106     @Override
isEnabled(int position)107     public boolean isEnabled(int position) {
108         return getItemViewType(position) == TYPE_LOCALE
109                 || getItemViewType(position) == TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER
110                 || getItemViewType(position) == TYPE_CURRENT_LOCALE;
111     }
112 
113     @Override
getItemViewType(int position)114     public int getItemViewType(int position) {
115         if (!showHeaders()) {
116             LocaleStore.LocaleInfo item = (LocaleStore.LocaleInfo) getItem(position);
117             if (item.isSystemLocale()) {
118                 return TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER;
119             }
120             if (item.isAppCurrentLocale()) {
121                 return TYPE_CURRENT_LOCALE;
122             }
123             return TYPE_LOCALE;
124         } else {
125             if (position == 0) {
126                 return TYPE_HEADER_SUGGESTED;
127             }
128             if (position == mSuggestionCount + 1) {
129                 return TYPE_HEADER_ALL_OTHERS;
130             }
131 
132             LocaleStore.LocaleInfo item = (LocaleStore.LocaleInfo) getItem(position);
133             if (item == null) {
134                 throw new NullPointerException("Non header locale cannot be null");
135             }
136             if (item.isSystemLocale()) {
137                 return TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER;
138             }
139             if (item.isAppCurrentLocale()) {
140                 return TYPE_CURRENT_LOCALE;
141             }
142             return TYPE_LOCALE;
143         }
144     }
145 
146     @Override
getViewTypeCount()147     public int getViewTypeCount() {
148         if (mHasSpecificAppPackageName && showHeaders()) {
149             // Two headers, 1 "System language", 1 current locale
150             return APP_LANGUAGE_PICKER_TYPE_COUNT;
151         } else if (showHeaders()) {
152             // Two headers in addition to the locales
153             return SYSTEM_LANGUAGE_TYPE_COUNT;
154         } else {
155             return SYSTEM_LANGUAGE_WITHOUT_HEADER_TYPE_COUNT; // Locales items only
156         }
157     }
158 
159     @Override
getCount()160     public int getCount() {
161         if (showHeaders()) {
162             return mLocaleOptions.size() + 2; // 2 extra for the headers
163         } else {
164             return mLocaleOptions.size();
165         }
166     }
167 
168     @Override
getItem(int position)169     public Object getItem(int position) {
170         if (isHeaderPosition(position)) {
171             return null;
172         }
173 
174         int offset = 0;
175         if (showHeaders()) {
176             offset = position > mSuggestionCount ? -2 : -1;
177         }
178 
179         return mLocaleOptions.get(position + offset);
180     }
181 
isHeaderPosition(int position)182     private boolean isHeaderPosition(int position) {
183         return showHeaders() && (position == 0 || position == mSuggestionCount + 1);
184     }
185 
186     @Override
getItemId(int position)187     public long getItemId(int position) {
188         return position;
189     }
190 
191     /**
192      * Overrides the locale used to display localized labels. Setting the locale to null will reset
193      * the Adapter to use the default locale for the labels.
194      */
setDisplayLocale(@onNull Context context, @Nullable Locale locale)195     public void setDisplayLocale(@NonNull Context context, @Nullable Locale locale) {
196         if (locale == null) {
197             mDisplayLocale = null;
198             mContextOverride = null;
199         } else if (!locale.equals(mDisplayLocale)) {
200             mDisplayLocale = locale;
201             final Configuration configOverride = new Configuration();
202             configOverride.setLocale(locale);
203             mContextOverride = context.createConfigurationContext(configOverride);
204         }
205     }
206 
setTextTo(@onNull TextView textView, int resId)207     protected void setTextTo(@NonNull TextView textView, int resId) {
208         if (mContextOverride == null) {
209             textView.setText(resId);
210         } else {
211             textView.setText(mContextOverride.getText(resId));
212             // If mContextOverride is not null, mDisplayLocale can't be null either.
213         }
214     }
215 
216     @Override
getView(int position, View convertView, ViewGroup parent)217     public View getView(int position, View convertView, ViewGroup parent) {
218         if (convertView == null && mInflater == null) {
219             mInflater = LayoutInflater.from(parent.getContext());
220         }
221         int itemType = getItemViewType(position);
222         View itemView = getNewViewIfNeeded(convertView, parent, itemType, position);
223         switch (itemType) {
224             case TYPE_HEADER_SUGGESTED: // intentional fallthrough
225             case TYPE_HEADER_ALL_OTHERS:
226                 TextView textView = (TextView) itemView;
227                 if (itemType == TYPE_HEADER_SUGGESTED) {
228                     if (mCountryMode && !mIsNumberingMode) {
229                         setTextTo(textView, R.string.language_picker_regions_section_suggested);
230                     } else {
231                         setTextTo(textView, R.string.language_picker_section_suggested);
232                     }
233                 } else {
234                     if (mCountryMode && !mIsNumberingMode) {
235                         setTextTo(textView, R.string.region_picker_section_all);
236                     } else {
237                         setTextTo(textView, R.string.language_picker_section_all);
238                     }
239                 }
240                 textView.setTextLocale(
241                         mDisplayLocale != null ? mDisplayLocale : Locale.getDefault());
242                 break;
243             case TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER:
244                 TextView title;
245                 LocaleStore.LocaleInfo info = (LocaleStore.LocaleInfo) getItem(position);
246                 if (info == null) {
247                     throw new NullPointerException("Non header locale cannot be null.");
248                 }
249                 if (info.isAppCurrentLocale()) {
250                     title = itemView.findViewById(R.id.language_picker_item);
251                 } else {
252                     title = itemView.findViewById(R.id.locale);
253                 }
254                 title.setText(R.string.system_locale_title);
255                 break;
256             case TYPE_CURRENT_LOCALE:
257                 updateTextView(itemView,
258                         itemView.findViewById(R.id.language_picker_item), position);
259                 break;
260             default:
261                 updateTextView(itemView, itemView.findViewById(R.id.locale), position);
262                 break;
263         }
264         return itemView;
265     }
266 
267     /** Check if the old view can be reused, otherwise create a new one. */
getNewViewIfNeeded( View convertView, ViewGroup parent, int itemType, int position)268     private View getNewViewIfNeeded(
269             View convertView, ViewGroup parent, int itemType, int position) {
270         View updatedView = convertView;
271         boolean shouldReuseView;
272         switch (itemType) {
273             case TYPE_HEADER_SUGGESTED: // intentional fallthrough
274             case TYPE_HEADER_ALL_OTHERS:
275                 shouldReuseView = convertView instanceof TextView
276                         && convertView.findViewById(R.id.language_picker_header) != null;
277                 if (!shouldReuseView) {
278                     updatedView = mInflater.inflate(
279                             R.layout.language_picker_section_header, parent, false);
280                 }
281                 break;
282             case TYPE_SYSTEM_LANGUAGE_FOR_APP_LANGUAGE_PICKER:
283                 if (((LocaleStore.LocaleInfo) getItem(position)).isAppCurrentLocale()) {
284                     shouldReuseView = convertView instanceof LinearLayout
285                             && convertView.findViewById(R.id.language_picker_item) != null;
286                     if (!shouldReuseView) {
287                         updatedView = mInflater.inflate(
288                                 R.layout.app_language_picker_current_locale_item,
289                                 parent, false);
290                     }
291                 } else {
292                     shouldReuseView = convertView instanceof TextView
293                             && convertView.findViewById(R.id.locale) != null;
294                     if (!shouldReuseView) {
295                         updatedView = mInflater.inflate(
296                                 R.layout.language_picker_item, parent, false);
297                     }
298                 }
299                 break;
300             case TYPE_CURRENT_LOCALE:
301                 shouldReuseView = convertView instanceof LinearLayout
302                         && convertView.findViewById(R.id.language_picker_item) != null;
303                 if (!shouldReuseView) {
304                     updatedView = mInflater.inflate(
305                             R.layout.app_language_picker_current_locale_item, parent, false);
306                 }
307                 break;
308             default:
309                 shouldReuseView = convertView instanceof TextView
310                         && convertView.findViewById(R.id.locale) != null;
311                 if (!shouldReuseView) {
312                     updatedView = mInflater.inflate(R.layout.language_picker_item, parent, false);
313                 }
314                 break;
315         }
316         return updatedView;
317     }
318 
showHeaders()319     protected boolean showHeaders() {
320         // We don't want to show suggestions for locales with very few regions
321         // (e.g. Romanian, with 2 regions)
322         // So we put a (somewhat) arbitrary limit.
323         //
324         // The initial idea was to make that limit dependent on the screen height.
325         // But that would mean rotating the screen could make the suggestions disappear,
326         // as the number of countries that fits on the screen would be different in portrait
327         // and landscape mode.
328         if (mCountryMode && mLocaleOptions.size() < MIN_REGIONS_FOR_SUGGESTIONS) {
329             return false;
330         }
331         return mSuggestionCount != 0 && mSuggestionCount != mLocaleOptions.size();
332     }
333 
334     /**
335      * Sorts the items in the adapter using a locale-aware comparator.
336      * @param comp The locale-aware comparator to use.
337      */
sort(LocaleHelper.LocaleInfoComparator comp)338     public void sort(LocaleHelper.LocaleInfoComparator comp) {
339         Collections.sort(mLocaleOptions, comp);
340     }
341 
342     class FilterByNativeAndUiNames extends Filter {
343 
344         @Override
performFiltering(CharSequence prefix)345         protected FilterResults performFiltering(CharSequence prefix) {
346             FilterResults results = new FilterResults();
347 
348             if (mOriginalLocaleOptions == null) {
349                 mOriginalLocaleOptions = new ArrayList<>(mLocaleOptions);
350             }
351 
352             ArrayList<LocaleStore.LocaleInfo> values;
353             values = new ArrayList<>(mOriginalLocaleOptions);
354             if (prefix == null || prefix.length() == 0) {
355                 results.values = values;
356                 results.count = values.size();
357             } else {
358                 // TODO: decide if we should use the string's locale
359                 Locale locale = Locale.getDefault();
360                 String prefixString = LocaleHelper.normalizeForSearch(prefix.toString(), locale);
361 
362                 final int count = values.size();
363                 final ArrayList<LocaleStore.LocaleInfo> newValues = new ArrayList<>();
364 
365                 for (int i = 0; i < count; i++) {
366                     final LocaleStore.LocaleInfo value = values.get(i);
367                     final String nameToCheck = LocaleHelper.normalizeForSearch(
368                             value.getFullNameInUiLanguage(), locale);
369                     final String nativeNameToCheck = LocaleHelper.normalizeForSearch(
370                             value.getFullNameNative(), locale);
371                     if (wordMatches(nativeNameToCheck, prefixString)
372                             || wordMatches(nameToCheck, prefixString)) {
373                         newValues.add(value);
374                     }
375                 }
376 
377                 results.values = newValues;
378                 results.count = newValues.size();
379             }
380 
381             return results;
382         }
383 
384         // TODO: decide if this is enough, or we want to use a BreakIterator...
wordMatches(String valueText, String prefixString)385         boolean wordMatches(String valueText, String prefixString) {
386             // First match against the whole, non-split value
387             if (valueText.startsWith(prefixString)) {
388                 return true;
389             }
390 
391             final String[] words = valueText.split(" ");
392             // Start at index 0, in case valueText starts with space(s)
393             for (String word : words) {
394                 if (word.startsWith(prefixString)) {
395                     return true;
396                 }
397             }
398 
399             return false;
400         }
401 
402         @Override
publishResults(CharSequence constraint, FilterResults results)403         protected void publishResults(CharSequence constraint, FilterResults results) {
404             mLocaleOptions = (ArrayList<LocaleStore.LocaleInfo>) results.values;
405 
406             mSuggestionCount = 0;
407             for (LocaleStore.LocaleInfo li : mLocaleOptions) {
408                 if (li.isSuggested()) {
409                     mSuggestionCount++;
410                 }
411             }
412 
413             if (results.count > 0) {
414                 notifyDataSetChanged();
415             } else {
416                 notifyDataSetInvalidated();
417             }
418         }
419     }
420 
421     @Override
getFilter()422     public Filter getFilter() {
423         return new FilterByNativeAndUiNames();
424     }
425 
updateTextView(View convertView, TextView text, int position)426     private void updateTextView(View convertView, TextView text, int position) {
427         LocaleStore.LocaleInfo item = (LocaleStore.LocaleInfo) getItem(position);
428         text.setText(mIsNumberingMode
429                 ? item.getNumberingSystem() : item.getLabel(mCountryMode));
430         text.setTextLocale(item.getLocale());
431         text.setContentDescription(mIsNumberingMode
432                         ? item.getNumberingSystem() : item.getContentDescription(mCountryMode));
433         if (mCountryMode) {
434             int layoutDir = TextUtils.getLayoutDirectionFromLocale(item.getParent());
435             //noinspection ResourceType
436             convertView.setLayoutDirection(layoutDir);
437             text.setTextDirection(layoutDir == View.LAYOUT_DIRECTION_RTL
438                     ? View.TEXT_DIRECTION_RTL
439                     : View.TEXT_DIRECTION_LTR);
440         }
441     }
442 }
443