1 /*
2  * Copyright 2019 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.car.ui.preference;
18 
19 import static com.android.car.ui.core.CarUi.MIN_TARGET_API;
20 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId;
21 
22 import android.content.Context;
23 import android.os.Bundle;
24 import android.util.Log;
25 import android.util.Pair;
26 import android.view.LayoutInflater;
27 import android.view.View;
28 import android.view.ViewGroup;
29 
30 import androidx.annotation.NonNull;
31 import androidx.annotation.Nullable;
32 import androidx.annotation.RequiresApi;
33 import androidx.fragment.app.DialogFragment;
34 import androidx.fragment.app.Fragment;
35 import androidx.preference.DialogPreference;
36 import androidx.preference.DropDownPreference;
37 import androidx.preference.EditTextPreference;
38 import androidx.preference.ListPreference;
39 import androidx.preference.MultiSelectListPreference;
40 import androidx.preference.Preference;
41 import androidx.preference.PreferenceFragmentCompat;
42 import androidx.preference.PreferenceGroup;
43 import androidx.preference.PreferenceScreen;
44 import androidx.preference.SwitchPreference;
45 import androidx.preference.TwoStatePreference;
46 import androidx.recyclerview.widget.RecyclerView;
47 
48 import com.android.car.ui.FocusArea;
49 import com.android.car.ui.R;
50 import com.android.car.ui.baselayout.Insets;
51 import com.android.car.ui.baselayout.InsetsChangedListener;
52 import com.android.car.ui.core.CarUi;
53 import com.android.car.ui.recyclerview.CarUiRecyclerView;
54 import com.android.car.ui.toolbar.Toolbar;
55 import com.android.car.ui.toolbar.ToolbarController;
56 import com.android.car.ui.utils.CarUiUtils;
57 
58 import java.util.ArrayDeque;
59 import java.util.ArrayList;
60 import java.util.Arrays;
61 import java.util.Deque;
62 import java.util.HashMap;
63 import java.util.List;
64 import java.util.Map;
65 
66 /**
67  * A PreferenceFragmentCompat is the entry point to using the Preference library.
68  *
69  * <p>Using this fragment will replace regular Preferences with CarUi equivalents. Because of this,
70  * certain properties that cannot be read out of Preferences will be lost upon calling
71  * {@link #setPreferenceScreen(PreferenceScreen)}. These include the preference viewId,
72  * defaultValue, and enabled state.
73  */
74 @SuppressWarnings("AndroidJdkLibsChecker")
75 @RequiresApi(MIN_TARGET_API)
76 public abstract class PreferenceFragment extends PreferenceFragmentCompat implements
77         InsetsChangedListener {
78 
79     /**
80      * Only for PreferenceFragment internal usage. Apps shouldn't use this as the
81      * {@link RecyclerView} that's provided here is not the real RecyclerView and has very limited
82      * functionality.
83      */
84     public interface AndroidxRecyclerViewProvider {
85 
86         /**
87          * returns instance of {@link RecyclerView} that proxies PreferenceFragment calls to the
88          * real RecyclerView implementation.
89          */
getRecyclerView()90         RecyclerView getRecyclerView();
91     }
92 
93     private static final String TAG = "CarUiPreferenceFragment";
94     private static final String DIALOG_FRAGMENT_TAG =
95             "com.android.car.ui.PreferenceFragment.DIALOG";
96 
97     @NonNull
98     private CarUiRecyclerView mCarUiRecyclerView;
99     @Nullable
100     private String mLastSelectedPrefKey;
101     private int mLastFocusedAndSelectedPrefPosition;
102 
103     @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)104     public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
105         super.onViewCreated(view, savedInstanceState);
106 
107         ToolbarController toolbar = getPreferenceToolbar(this);
108         if (toolbar != null) {
109             setupToolbar(toolbar);
110         }
111     }
112 
113     /**
114      * Sets up what the toolbar should display when on this PreferenceFragment.
115      *
116      * This can be overridden in subclasses to customize the toolbar. By default it puts a back
117      * button on the toolbar, and sets its title to the {@link PreferenceScreen PreferenceScreen's}
118      * title.
119      *
120      * @param toolbar The toolbar from {@link #getPreferenceToolbar(Fragment)}, where the Fragment
121      *                passed to getToolbar() is this fragment.
122      */
setupToolbar(@onNull ToolbarController toolbar)123     protected void setupToolbar(@NonNull ToolbarController toolbar) {
124         toolbar.setNavButtonMode(Toolbar.NavButtonMode.BACK);
125         PreferenceScreen preferenceScreen = getPreferenceScreen();
126         if (preferenceScreen != null) {
127             toolbar.setTitle(preferenceScreen.getTitle());
128         } else {
129             toolbar.setTitle("");
130         }
131     }
132 
133     /**
134      * Gets the toolbar for the given fragment. The fragment can be either this PreferenceFragment,
135      * or one of the fragments that are created from it such as {@link ListPreferenceFragment}.
136      *
137      * This can be overridden by subclasses to have the fragments use a different toolbar.
138      *
139      * @see #getPreferenceInsets(Fragment)
140      * @param fragment The fragment to get a toolbar for. Either this fragment, or one of the
141      *                 fragments that it launches.
142      */
143     @Nullable
getPreferenceToolbar(@onNull Fragment fragment)144     protected ToolbarController getPreferenceToolbar(@NonNull Fragment fragment) {
145         return CarUi.getToolbar(getActivity());
146     }
147 
148     /**
149      * Gets the {@link Insets} for the given fragment. The fragment can be either this
150      * PreferenceFragment, or one of the fragments that are created from it such as
151      * {@link ListPreferenceFragment}.
152      *
153      * This can be overridden by subclasses to have the fragments use different insets.
154      *
155      * @see #getPreferenceToolbar(Fragment)
156      * @param fragment The fragment to get insets for. Either this fragment, or one of the
157      *                 fragments that it launches.
158      */
159     @Nullable
getPreferenceInsets(@onNull Fragment fragment)160     protected Insets getPreferenceInsets(@NonNull Fragment fragment) {
161         return CarUi.getInsets(getActivity());
162     }
163 
164     @Override
onStart()165     public void onStart() {
166         super.onStart();
167         Insets insets = getPreferenceInsets(this);
168         if (insets != null) {
169             onCarUiInsetsChanged(insets);
170         }
171     }
172 
173     @Override
onCarUiInsetsChanged(@onNull Insets insets)174     public void onCarUiInsetsChanged(@NonNull Insets insets) {
175         View view = requireView();
176         FocusArea focusArea = requireViewByRefId(view, R.id.car_ui_focus_area);
177         focusArea.setHighlightPadding(0, insets.getTop(), 0, insets.getBottom());
178         focusArea.setBoundsOffset(0, insets.getTop(), 0, insets.getBottom());
179         getCarUiRecyclerView().setPadding(0, insets.getTop(), 0, insets.getBottom());
180         view.setPadding(insets.getLeft(), 0, insets.getRight(), 0);
181     }
182 
183     /**
184      * Called when a preference in the tree requests to display a dialog. Subclasses should override
185      * this method to display custom dialogs or to handle dialogs for custom preference classes.
186      *
187      * <p>Note: this is borrowed as-is from androidx.preference.PreferenceFragmentCompat with
188      * updates to launch Car UI library {@link DialogFragment} instead of the ones in the
189      * support library.
190      *
191      * @param preference The {@link Preference} object requesting the dialog
192      */
193     @Override
onDisplayPreferenceDialog(Preference preference)194     public void onDisplayPreferenceDialog(Preference preference) {
195 
196         if (getActivity() instanceof OnPreferenceDisplayDialogCallback
197                 && ((OnPreferenceDisplayDialogCallback) getActivity())
198                 .onPreferenceDisplayDialog(this, preference)) {
199             return;
200         }
201 
202         // check if dialog is already showing
203         if (requireFragmentManager().findFragmentByTag(DIALOG_FRAGMENT_TAG) != null) {
204             return;
205         }
206 
207         final Fragment f;
208         if (preference instanceof EditTextPreference) {
209             f = EditTextPreferenceDialogFragment.newInstance(preference.getKey());
210         } else if (preference instanceof ListPreference) {
211             f = ListPreferenceFragment.newInstance(preference.getKey());
212         } else if (preference instanceof MultiSelectListPreference) {
213             f = MultiSelectListPreferenceFragment.newInstance(preference.getKey());
214         } else if (preference instanceof CarUiSeekBarDialogPreference) {
215             f = SeekbarPreferenceDialogFragment.newInstance(preference.getKey());
216         } else {
217             throw new IllegalArgumentException(
218                     "Cannot display dialog for an unknown Preference type: "
219                             + preference.getClass().getSimpleName()
220                             + ". Make sure to implement onPreferenceDisplayDialog() to handle "
221                             + "displaying a custom dialog for this Preference.");
222         }
223 
224         f.setTargetFragment(this, 0);
225 
226         if (f instanceof DialogFragment) {
227             ((DialogFragment) f).show(getFragmentManager(), DIALOG_FRAGMENT_TAG);
228         } else {
229             if (getActivity() == null) {
230                 throw new IllegalStateException(
231                         "Preference fragment is not attached to an Activity.");
232             }
233 
234             if (getView() == null) {
235                 throw new IllegalStateException(
236                         "Preference fragment must have a layout.");
237             }
238 
239             Context context = getContext();
240             getParentFragmentManager().beginTransaction()
241                     .setCustomAnimations(
242                             CarUiUtils.getAttrResourceId(context,
243                                     android.R.attr.fragmentOpenEnterAnimation),
244                             CarUiUtils.getAttrResourceId(context,
245                                     android.R.attr.fragmentOpenExitAnimation),
246                             CarUiUtils.getAttrResourceId(context,
247                                     android.R.attr.fragmentCloseEnterAnimation),
248                             CarUiUtils.getAttrResourceId(context,
249                                     android.R.attr.fragmentCloseExitAnimation))
250                     .replace(((ViewGroup) getView().getParent()).getId(), f)
251                     .addToBackStack(null)
252                     .commit();
253         }
254     }
255 
256     @Override
onResume()257     public void onResume() {
258         super.onResume();
259         if (mLastSelectedPrefKey != null) {
260             scrollToPreference(mLastSelectedPrefKey);
261         }
262     }
263 
264     @Override
onPreferenceTreeClick(Preference preference)265     public boolean onPreferenceTreeClick(Preference preference) {
266         mLastSelectedPrefKey = preference.getKey();
267         View focus = getView().findFocus();
268         mLastFocusedAndSelectedPrefPosition = mCarUiRecyclerView.getChildLayoutPosition(focus);
269 
270         return super.onPreferenceTreeClick(preference);
271     }
272 
273     /**
274      * This override of setPreferenceScreen replaces preferences with their CarUi versions first.
275      */
276     @Override
setPreferenceScreen(PreferenceScreen preferenceScreen)277     public void setPreferenceScreen(PreferenceScreen preferenceScreen) {
278         // We do a search of the tree and every time we see a PreferenceGroup we remove
279         // all it's children, replace them with CarUi versions, and then re-add them
280 
281         Map<Preference, String> dependencies = new HashMap<>();
282         List<Preference> children = new ArrayList<>();
283 
284         // Stack of preferences to process
285         Deque<Preference> stack = new ArrayDeque<>();
286         stack.addFirst(preferenceScreen);
287 
288         while (!stack.isEmpty()) {
289             Preference preference = stack.removeFirst();
290 
291             if (preference instanceof PreferenceGroup) {
292                 PreferenceGroup pg = (PreferenceGroup) preference;
293 
294                 children.clear();
295                 for (int i = 0; i < pg.getPreferenceCount(); i++) {
296                     children.add(pg.getPreference(i));
297                 }
298 
299                 pg.removeAll();
300 
301                 for (Preference child : children) {
302                     Preference replacement = getReplacementFor(child);
303 
304                     dependencies.put(replacement, child.getDependency());
305                     pg.addPreference(replacement);
306                     stack.addFirst(replacement);
307                 }
308             }
309         }
310 
311         super.setPreferenceScreen(preferenceScreen);
312 
313         // Set the dependencies after all the swapping has been done and they've been
314         // associated with this fragment, or we could potentially fail to find preferences
315         // or use the wrong preferenceManager
316         for (Map.Entry<Preference, String> entry : dependencies.entrySet()) {
317             entry.getKey().setDependency(entry.getValue());
318         }
319     }
320 
321     /**
322      * In order to change the layout for {@link PreferenceFragment}, make sure the correct layout is
323      * passed to PreferenceFragment.CarUi theme.
324      * Override ht method in order to inflate {@link CarUiRecyclerView}
325      */
326     @NonNull
onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)327     public CarUiRecyclerView onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent,
328                                                        Bundle savedInstanceState) {
329         return requireViewByRefId(parent, R.id.recycler_view);
330     }
331 
332     @NonNull
getCarUiRecyclerView()333     public CarUiRecyclerView getCarUiRecyclerView() {
334         return mCarUiRecyclerView;
335     }
336 
337     // Mapping from regular preferences to CarUi preferences.
338     // Order is important, subclasses must come before their base classes
339     // Make sure all the following classes are added to proguard configuration.
340     private static final List<Pair<Class<? extends Preference>, Class<? extends Preference>>>
341             sPreferenceMapping = Arrays.asList(
342             new Pair<>(DropDownPreference.class, CarUiDropDownPreference.class),
343             new Pair<>(ListPreference.class, CarUiListPreference.class),
344             new Pair<>(MultiSelectListPreference.class, CarUiMultiSelectListPreference.class),
345             new Pair<>(EditTextPreference.class, CarUiEditTextPreference.class),
346             new Pair<>(SwitchPreference.class, CarUiSwitchPreference.class),
347             new Pair<>(Preference.class, CarUiPreference.class)
348     );
349 
350     /**
351      * Gets the CarUi version of the passed in preference. If there is no suitable replacement, this
352      * method will return it's input.
353      *
354      * <p>When given a Preference that extends a replaceable preference, we log a warning instead
355      * of replacing it so that we don't remove any functionality.
356      */
getReplacementFor(Preference preference)357     private static Preference getReplacementFor(Preference preference) {
358         Class<? extends Preference> clazz = preference.getClass();
359 
360         for (Pair<Class<? extends Preference>, Class<? extends Preference>> replacement
361                 : sPreferenceMapping) {
362             Class<? extends Preference> source = replacement.first;
363             Class<? extends Preference> target = replacement.second;
364             if (source.isAssignableFrom(clazz)) {
365                 if (clazz == source) {
366                     try {
367                         return copyPreference(preference, (Preference) target
368                                 .getDeclaredConstructor(Context.class)
369                                 .newInstance(preference.getContext()));
370                     } catch (ReflectiveOperationException e) {
371                         throw new RuntimeException(e);
372                     }
373                 } else if (clazz == target || source == Preference.class) {
374                     // Don't warn about subclasses of Preference because there are many legitimate
375                     // uses for non-carui Preference subclasses, like Preference groups.
376                     return preference;
377                 } else {
378                     Log.w(TAG, "Subclass of " + source.getSimpleName() + " was used, "
379                             + "preventing us from substituting it with " + target.getSimpleName());
380                     return preference;
381                 }
382             }
383         }
384 
385         return preference;
386     }
387 
388     @Override
onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)389     public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent,
390             Bundle savedInstanceState) {
391         mCarUiRecyclerView = onCreateCarUiRecyclerView(inflater, parent, savedInstanceState);
392         RecyclerView recyclerView = null;
393         if (mCarUiRecyclerView instanceof AndroidxRecyclerViewProvider) {
394             recyclerView = ((AndroidxRecyclerViewProvider) mCarUiRecyclerView).getRecyclerView();
395         }
396         if (recyclerView == null) {
397             recyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState);
398         }
399 
400         // When not in touch mode, focus on the previously focused and selected item, if any.
401         if (mCarUiRecyclerView != null) {
402             mCarUiRecyclerView.addOnChildAttachStateChangeListener(
403                         new RecyclerView.OnChildAttachStateChangeListener() {
404                             @Override
405                             public void onChildViewAttachedToWindow(View view) {
406                                 int position = mCarUiRecyclerView.getChildLayoutPosition(view);
407                                 if (position == mLastFocusedAndSelectedPrefPosition) {
408                                     view.requestFocus();
409                                 }
410                             }
411                             @Override
412                             public void onChildViewDetachedFromWindow(View view) {
413                             }
414                 });
415         }
416         return recyclerView;
417     }
418 
419     /**
420      * Copies all the properties of one preference to another.
421      *
422      * @return the {@code to} parameter
423      */
copyPreference(Preference from, Preference to)424     private static Preference copyPreference(Preference from, Preference to) {
425         // viewId and defaultValue don't have getters
426         // isEnabled() is not completely symmetrical with setEnabled(), so we can't use it.
427         to.setTitle(from.getTitle());
428         to.setOnPreferenceClickListener(from.getOnPreferenceClickListener());
429         to.setOnPreferenceChangeListener(from.getOnPreferenceChangeListener());
430         to.setIcon(from.getIcon());
431         to.setFragment(from.getFragment());
432         to.setIntent(from.getIntent());
433         to.setKey(from.getKey());
434         to.setOrder(from.getOrder());
435         to.setSelectable(from.isSelectable());
436         to.setPersistent(from.isPersistent());
437         to.setIconSpaceReserved(from.isIconSpaceReserved());
438         to.setWidgetLayoutResource(from.getWidgetLayoutResource());
439         to.setPreferenceDataStore(from.getPreferenceDataStore());
440         to.setSingleLineTitle(from.isSingleLineTitle());
441         to.setVisible(from.isVisible());
442         to.setLayoutResource(from.getLayoutResource());
443         to.setCopyingEnabled(from.isCopyingEnabled());
444 
445         if (!(to instanceof UxRestrictablePreference)) {
446             to.setShouldDisableView(from.getShouldDisableView());
447         }
448 
449         if (from.getSummaryProvider() != null) {
450             to.setSummaryProvider(from.getSummaryProvider());
451         } else {
452             to.setSummary(from.getSummary());
453         }
454 
455         if (from.peekExtras() != null) {
456             to.getExtras().putAll(from.peekExtras());
457         }
458 
459         if (from instanceof DialogPreference) {
460             DialogPreference fromDialog = (DialogPreference) from;
461             DialogPreference toDialog = (DialogPreference) to;
462             toDialog.setDialogTitle(fromDialog.getDialogTitle());
463             toDialog.setDialogIcon(fromDialog.getDialogIcon());
464             toDialog.setDialogMessage(fromDialog.getDialogMessage());
465             toDialog.setDialogLayoutResource(fromDialog.getDialogLayoutResource());
466             toDialog.setNegativeButtonText(fromDialog.getNegativeButtonText());
467             toDialog.setPositiveButtonText(fromDialog.getPositiveButtonText());
468         }
469 
470         // DropDownPreference extends ListPreference and doesn't add any extra api surface,
471         // so we don't need a case for it
472         if (from instanceof ListPreference) {
473             ListPreference fromList = (ListPreference) from;
474             ListPreference toList = (ListPreference) to;
475             toList.setEntries(fromList.getEntries());
476             toList.setEntryValues(fromList.getEntryValues());
477             toList.setValue(fromList.getValue());
478         } else if (from instanceof EditTextPreference) {
479             EditTextPreference fromText = (EditTextPreference) from;
480             EditTextPreference toText = (EditTextPreference) to;
481             toText.setText(fromText.getText());
482         } else if (from instanceof MultiSelectListPreference) {
483             MultiSelectListPreference fromMulti = (MultiSelectListPreference) from;
484             MultiSelectListPreference toMulti = (MultiSelectListPreference) to;
485             toMulti.setEntries(fromMulti.getEntries());
486             toMulti.setEntryValues(fromMulti.getEntryValues());
487             toMulti.setValues(fromMulti.getValues());
488         } else if (from instanceof TwoStatePreference) {
489             TwoStatePreference fromTwoState = (TwoStatePreference) from;
490             TwoStatePreference toTwoState = (TwoStatePreference) to;
491             toTwoState.setSummaryOn(fromTwoState.getSummaryOn());
492             toTwoState.setSummaryOff(fromTwoState.getSummaryOff());
493 
494             if (from instanceof SwitchPreference) {
495                 SwitchPreference fromSwitch = (SwitchPreference) from;
496                 SwitchPreference toSwitch = (SwitchPreference) to;
497                 toSwitch.setSwitchTextOn(fromSwitch.getSwitchTextOn());
498                 toSwitch.setSwitchTextOff(fromSwitch.getSwitchTextOff());
499             }
500         }
501 
502         // We don't need to add checks for things that we will never replace,
503         // like PreferenceGroup or CheckBoxPreference
504 
505         return to;
506     }
507 }
508