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.settings.localepicker;
18 
19 import android.app.settings.SettingsEnums;
20 import android.content.Context;
21 import android.graphics.Canvas;
22 import android.os.Bundle;
23 import android.os.LocaleList;
24 import android.text.TextUtils;
25 import android.util.Log;
26 import android.util.TypedValue;
27 import android.view.LayoutInflater;
28 import android.view.MotionEvent;
29 import android.view.View;
30 import android.view.View.OnClickListener;
31 import android.view.ViewGroup;
32 import android.widget.CheckBox;
33 import android.widget.CompoundButton;
34 
35 import androidx.annotation.VisibleForTesting;
36 import androidx.core.view.MotionEventCompat;
37 import androidx.recyclerview.widget.ItemTouchHelper;
38 import androidx.recyclerview.widget.RecyclerView;
39 
40 import com.android.internal.app.LocalePicker;
41 import com.android.internal.app.LocaleStore;
42 import com.android.settings.R;
43 import com.android.settings.overlay.FeatureFactory;
44 import com.android.settings.shortcut.ShortcutsUpdateTask;
45 
46 import java.text.NumberFormat;
47 import java.util.ArrayList;
48 import java.util.List;
49 import java.util.Locale;
50 
51 class LocaleDragAndDropAdapter
52         extends RecyclerView.Adapter<LocaleDragAndDropAdapter.CustomViewHolder> {
53 
54     private static final String TAG = "LocaleDragAndDropAdapter";
55     private static final String CFGKEY_SELECTED_LOCALES = "selectedLocales";
56     private static final String CFGKEY_DRAG_LOCALE = "dragLocales";
57 
58     private final Context mContext;
59     private final ItemTouchHelper mItemTouchHelper;
60 
61     private List<LocaleStore.LocaleInfo> mFeedItemList;
62     private List<LocaleStore.LocaleInfo> mCacheItemList;
63     private RecyclerView mParentView = null;
64     private boolean mRemoveMode = false;
65     private boolean mDragEnabled = true;
66     private NumberFormat mNumberFormatter = NumberFormat.getNumberInstance();
67     private LocaleStore.LocaleInfo mDragLocale;
68 
69     class CustomViewHolder extends RecyclerView.ViewHolder implements View.OnTouchListener {
70         private final LocaleDragCell mLocaleDragCell;
71 
CustomViewHolder(LocaleDragCell view)72         public CustomViewHolder(LocaleDragCell view) {
73             super(view);
74             mLocaleDragCell = view;
75             mLocaleDragCell.getDragHandle().setOnTouchListener(this);
76         }
77 
getLocaleDragCell()78         public LocaleDragCell getLocaleDragCell() {
79             return mLocaleDragCell;
80         }
81 
82         @Override
onTouch(View v, MotionEvent event)83         public boolean onTouch(View v, MotionEvent event) {
84             if (mDragEnabled) {
85                 switch (MotionEventCompat.getActionMasked(event)) {
86                     case MotionEvent.ACTION_DOWN:
87                         mItemTouchHelper.startDrag(this);
88                 }
89             }
90             return false;
91         }
92     }
93 
LocaleDragAndDropAdapter(LocaleListEditor parent, List<LocaleStore.LocaleInfo> feedItemList)94     LocaleDragAndDropAdapter(LocaleListEditor parent, List<LocaleStore.LocaleInfo> feedItemList) {
95         mFeedItemList = feedItemList;
96         mCacheItemList = new ArrayList<>(feedItemList);
97         mContext = parent.getContext();
98 
99         final float dragElevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8,
100                 mContext.getResources().getDisplayMetrics());
101 
102         mItemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
103                 ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0 /* no swipe */) {
104 
105             @Override
106             public boolean onMove(RecyclerView view, RecyclerView.ViewHolder source,
107                     RecyclerView.ViewHolder target) {
108                 onItemMove(source.getAdapterPosition(), target.getAdapterPosition());
109                 return true;
110             }
111 
112             @Override
113             public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) {
114                 // Swipe is disabled, this is intentionally empty.
115             }
116 
117             private static final int SELECTION_GAINED = 1;
118             private static final int SELECTION_LOST = 0;
119             private static final int SELECTION_UNCHANGED = -1;
120             private int mSelectionStatus = SELECTION_UNCHANGED;
121 
122             @Override
123             public void onChildDraw(Canvas c, RecyclerView recyclerView,
124                     RecyclerView.ViewHolder viewHolder, float dX, float dY,
125                     int actionState, boolean isCurrentlyActive) {
126 
127                 super.onChildDraw(c, recyclerView, viewHolder, dX, dY,
128                         actionState, isCurrentlyActive);
129                 // We change the elevation if selection changed
130                 if (mSelectionStatus != SELECTION_UNCHANGED) {
131                     viewHolder.itemView.setElevation(
132                             mSelectionStatus == SELECTION_GAINED ? dragElevation : 0);
133                     mSelectionStatus = SELECTION_UNCHANGED;
134                 }
135             }
136 
137             @Override
138             public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
139                 super.onSelectedChanged(viewHolder, actionState);
140                 if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
141                     mSelectionStatus = SELECTION_GAINED;
142                 } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
143                     mSelectionStatus = SELECTION_LOST;
144                 }
145             }
146         });
147     }
148 
setRecyclerView(RecyclerView rv)149     public void setRecyclerView(RecyclerView rv) {
150         mParentView = rv;
151         mItemTouchHelper.attachToRecyclerView(rv);
152     }
153 
154     @Override
onCreateViewHolder(ViewGroup viewGroup, int i)155     public CustomViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
156         final LocaleDragCell item = (LocaleDragCell) LayoutInflater.from(mContext)
157                 .inflate(R.layout.locale_drag_cell, viewGroup, false);
158         return new CustomViewHolder(item);
159     }
160 
161     @Override
onBindViewHolder(final CustomViewHolder holder, int i)162     public void onBindViewHolder(final CustomViewHolder holder, int i) {
163         final LocaleStore.LocaleInfo feedItem = mFeedItemList.get(i);
164         final LocaleDragCell dragCell = holder.getLocaleDragCell();
165         final String label = feedItem.getFullNameNative();
166         final String description = feedItem.getFullNameInUiLanguage();
167 
168         dragCell.setLabelAndDescription(label, description);
169         dragCell.setLocalized(feedItem.isTranslated());
170         dragCell.setCurrentDefault(feedItem.getLocale().equals(Locale.getDefault()));
171         dragCell.setMiniLabel(mNumberFormatter.format(i + 1));
172         dragCell.setShowCheckbox(mRemoveMode);
173         dragCell.setShowMiniLabel(!mRemoveMode);
174         dragCell.setShowHandle(!mRemoveMode && mDragEnabled);
175         dragCell.setTag(feedItem);
176         CheckBox checkbox = dragCell.getCheckbox();
177         // clear listener before setChecked() in case another item already bind to
178         // current ViewHolder and checked event is triggered on stale listener mistakenly.
179         checkbox.setOnCheckedChangeListener(null);
180         boolean isChecked = mRemoveMode ? feedItem.getChecked() : false;
181         checkbox.setChecked(isChecked);
182         setCheckBoxDescription(dragCell, checkbox, isChecked);
183 
184         checkbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
185             @Override
186             public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
187                 LocaleStore.LocaleInfo feedItem =
188                         (LocaleStore.LocaleInfo) dragCell.getTag();
189                 feedItem.setChecked(isChecked);
190                 setCheckBoxDescription(dragCell, checkbox, isChecked);
191             }
192         });
193 
194         dragCell.setOnClickListener(new OnClickListener() {
195             @Override
196             public void onClick(View v) {
197                 checkbox.toggle();
198             }
199         });
200     }
201 
202     @VisibleForTesting
setCheckBoxDescription(LocaleDragCell dragCell, CheckBox checkbox, boolean isChecked)203     protected void setCheckBoxDescription(LocaleDragCell dragCell, CheckBox checkbox,
204             boolean isChecked) {
205         if (!mRemoveMode) {
206             return;
207         }
208         CharSequence checkedStatus = mContext.getText(
209                 isChecked ? com.android.internal.R.string.checked
210                         : com.android.internal.R.string.not_checked);
211         // Select to Speak
212         checkbox.setContentDescription(checkedStatus);
213     }
214 
215     @Override
getItemCount()216     public int getItemCount() {
217         int itemCount = (null != mFeedItemList ? mFeedItemList.size() : 0);
218         if (itemCount < 2 || mRemoveMode) {
219             setDragEnabled(false);
220         } else {
221             setDragEnabled(true);
222         }
223         return itemCount;
224     }
225 
onItemMove(int fromPosition, int toPosition)226     void onItemMove(int fromPosition, int toPosition) {
227         if (fromPosition >= 0 && toPosition >= 0) {
228             final LocaleStore.LocaleInfo saved = mFeedItemList.get(fromPosition);
229             mFeedItemList.remove(fromPosition);
230             mFeedItemList.add(toPosition, saved);
231             mDragLocale = saved;
232         } else {
233             // TODO: It looks like sometimes the RecycleView tries to swap item -1
234             // I did not see it in a while, but if it happens, investigate and file a bug.
235             Log.e(TAG, String.format(Locale.US,
236                     "Negative position in onItemMove %d -> %d", fromPosition, toPosition));
237         }
238 
239         if (fromPosition != toPosition) {
240             FeatureFactory.getFeatureFactory().getMetricsFeatureProvider()
241                     .action(mContext, SettingsEnums.ACTION_REORDER_LANGUAGE);
242         }
243 
244         notifyItemChanged(fromPosition); // to update the numbers
245         notifyItemChanged(toPosition);
246         notifyItemMoved(fromPosition, toPosition);
247         // We don't call doTheUpdate() here because this method is called for each item swap.
248         // So if we drag something across several positions it will be called several times.
249     }
250 
setRemoveMode(boolean removeMode)251     void setRemoveMode(boolean removeMode) {
252         mRemoveMode = removeMode;
253         int itemCount = mFeedItemList.size();
254         for (int i = 0; i < itemCount; i++) {
255             mFeedItemList.get(i).setChecked(false);
256             notifyItemChanged(i);
257         }
258     }
259 
isRemoveMode()260     boolean isRemoveMode() {
261         return mRemoveMode;
262     }
263 
removeItem(int position)264     void removeItem(int position) {
265         int itemCount = mFeedItemList.size();
266         if (itemCount <= 1) {
267             return;
268         }
269         if (position < 0 || position >= itemCount) {
270             return;
271         }
272         mFeedItemList.remove(position);
273         notifyDataSetChanged();
274     }
275 
removeChecked()276     void removeChecked() {
277         int itemCount = mFeedItemList.size();
278         LocaleStore.LocaleInfo localeInfo;
279         NotificationController controller = NotificationController.getInstance(mContext);
280         for (int i = itemCount - 1; i >= 0; i--) {
281             localeInfo = mFeedItemList.get(i);
282             if (localeInfo.getChecked()) {
283                 FeatureFactory.getFeatureFactory().getMetricsFeatureProvider()
284                         .action(mContext, SettingsEnums.ACTION_REMOVE_LANGUAGE);
285                 mFeedItemList.remove(i);
286                 controller.removeNotificationInfo(localeInfo.getLocale().toLanguageTag());
287             }
288         }
289         notifyDataSetChanged();
290         doTheUpdate();
291     }
292 
getCheckedCount()293     int getCheckedCount() {
294         int result = 0;
295         for (LocaleStore.LocaleInfo li : mFeedItemList) {
296             if (li.getChecked()) {
297                 result++;
298             }
299         }
300         return result;
301     }
302 
isFirstLocaleChecked()303     boolean isFirstLocaleChecked() {
304         return mFeedItemList != null && mFeedItemList.get(0).getChecked();
305     }
306 
addLocale(LocaleStore.LocaleInfo li)307     void addLocale(LocaleStore.LocaleInfo li) {
308         mFeedItemList.add(li);
309         notifyItemInserted(mFeedItemList.size() - 1);
310         doTheUpdate();
311     }
312 
doTheUpdate()313     public void doTheUpdate() {
314         int count = mFeedItemList.size();
315         final Locale[] newList = new Locale[count];
316 
317         for (int i = 0; i < count; i++) {
318             final LocaleStore.LocaleInfo li = mFeedItemList.get(i);
319             newList[i] = li.getLocale();
320         }
321 
322         final LocaleList ll = new LocaleList(newList);
323         updateLocalesWhenAnimationStops(ll);
324     }
325 
326     private LocaleList mLocalesToSetNext = null;
327     private LocaleList mLocalesSetLast = null;
328 
updateLocalesWhenAnimationStops(final LocaleList localeList)329     public void updateLocalesWhenAnimationStops(final LocaleList localeList) {
330         if (localeList.equals(mLocalesToSetNext)) {
331             return;
332         }
333 
334         // This will only update the Settings application to make things feel more responsive,
335         // the system will be updated later, when animation stopped.
336         LocaleList.setDefault(localeList);
337 
338         mLocalesToSetNext = localeList;
339         final RecyclerView.ItemAnimator itemAnimator = mParentView.getItemAnimator();
340         itemAnimator.isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
341             @Override
342             public void onAnimationsFinished() {
343                 if (mLocalesToSetNext == null || mLocalesToSetNext.equals(mLocalesSetLast)) {
344                     // All animations finished, but the locale list did not change
345                     return;
346                 }
347 
348                 LocalePicker.updateLocales(mLocalesToSetNext);
349                 mLocalesSetLast = mLocalesToSetNext;
350                 new ShortcutsUpdateTask(mContext).execute();
351 
352                 mLocalesToSetNext = null;
353 
354                 mNumberFormatter = NumberFormat.getNumberInstance(Locale.getDefault());
355             }
356         });
357     }
358 
notifyListChanged(LocaleStore.LocaleInfo localeInfo)359     public void notifyListChanged(LocaleStore.LocaleInfo localeInfo) {
360         if (!localeInfo.getLocale().equals(mCacheItemList.get(0).getLocale())) {
361             mFeedItemList = new ArrayList<>(mCacheItemList);
362             notifyDataSetChanged();
363         }
364     }
365 
setCacheItemList()366     public void setCacheItemList() {
367         mCacheItemList = new ArrayList<>(mFeedItemList);
368     }
369 
getFeedItemList()370     public List<LocaleStore.LocaleInfo> getFeedItemList() {
371         return mFeedItemList;
372     }
setDragEnabled(boolean enabled)373     private void setDragEnabled(boolean enabled) {
374         mDragEnabled = enabled;
375     }
376 
377     /**
378      * Saves the list of checked locales to preserve status when the list is destroyed.
379      * (for instance when the device is rotated)
380      *
381      * @param outInstanceState Bundle in which to place the saved state
382      */
saveState(Bundle outInstanceState)383     public void saveState(Bundle outInstanceState) {
384         if (outInstanceState != null) {
385             final ArrayList<String> selectedLocales = new ArrayList<>();
386             for (LocaleStore.LocaleInfo li : mFeedItemList) {
387                 if (li.getChecked()) {
388                     selectedLocales.add(li.getId());
389                 }
390             }
391             outInstanceState.putStringArrayList(CFGKEY_SELECTED_LOCALES, selectedLocales);
392             // Save the dragged locale before rotation
393             outInstanceState.putSerializable(CFGKEY_DRAG_LOCALE, mDragLocale);
394         }
395     }
396 
397     /**
398      * Restores the list of checked locales to preserve status when the list is recreated.
399      * (for instance when the device is rotated)
400      *
401      * @param savedInstanceState Bundle with the data saved by {@link #saveState(Bundle)}
402      * @param isDialogShowing A flag indicating whether the dialog is showing or not.
403      */
restoreState(Bundle savedInstanceState, boolean isDialogShowing)404     public void restoreState(Bundle savedInstanceState, boolean isDialogShowing) {
405         if (savedInstanceState != null) {
406             if (mRemoveMode) {
407                 final ArrayList<String> selectedLocales =
408                         savedInstanceState.getStringArrayList(CFGKEY_SELECTED_LOCALES);
409                 if (selectedLocales == null || selectedLocales.isEmpty()) {
410                     return;
411                 }
412                 for (LocaleStore.LocaleInfo li : mFeedItemList) {
413                     li.setChecked(selectedLocales.contains(li.getId()));
414                 }
415                 notifyItemRangeChanged(0, mFeedItemList.size());
416             } else if (isDialogShowing) {
417                 // After rotation, the dragged position will be restored to original. Restore the
418                 // drag locale's original position to the top.
419                 mDragLocale = (LocaleStore.LocaleInfo) savedInstanceState.getSerializable(
420                         CFGKEY_DRAG_LOCALE);
421                 if (mDragLocale != null) {
422                     mFeedItemList.removeIf(
423                             localeInfo -> TextUtils.equals(localeInfo.getId(),
424                                     mDragLocale.getId()));
425                     mFeedItemList.add(0, mDragLocale);
426                     notifyItemRangeChanged(0, mFeedItemList.size());
427                 }
428             }
429         }
430     }
431 }
432