1 /*
2  * Copyright 2018 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.settings.common;
18 
19 import static com.android.car.settings.common.BaseCarSettingsActivity.META_DATA_KEY_SINGLE_PANE;
20 
21 import android.car.drivingstate.CarUxRestrictions;
22 import android.car.drivingstate.CarUxRestrictionsManager;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.IntentSender;
26 import android.os.Bundle;
27 import android.util.ArrayMap;
28 import android.util.SparseArray;
29 import android.util.TypedValue;
30 import android.view.ContextThemeWrapper;
31 import android.view.LayoutInflater;
32 import android.view.ViewGroup;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.annotation.StringRes;
37 import androidx.annotation.VisibleForTesting;
38 import androidx.annotation.XmlRes;
39 import androidx.fragment.app.DialogFragment;
40 import androidx.fragment.app.Fragment;
41 import androidx.lifecycle.Lifecycle;
42 import androidx.preference.Preference;
43 import androidx.preference.PreferenceScreen;
44 import androidx.recyclerview.widget.RecyclerView;
45 
46 import com.android.car.settings.R;
47 import com.android.car.ui.baselayout.Insets;
48 import com.android.car.ui.preference.PreferenceFragment;
49 import com.android.car.ui.recyclerview.CarUiRecyclerView;
50 import com.android.car.ui.toolbar.MenuItem;
51 import com.android.car.ui.toolbar.NavButtonMode;
52 import com.android.car.ui.toolbar.ToolbarController;
53 import com.android.car.ui.utils.ViewUtils;
54 import com.android.settingslib.search.Indexable;
55 
56 import java.util.ArrayList;
57 import java.util.List;
58 import java.util.Map;
59 
60 /**
61  * Base fragment for all settings. Subclasses must provide a resource id via
62  * {@link #getPreferenceScreenResId()} for the XML resource which defines the preferences to
63  * display and controllers to update their state. This class is responsible for displaying the
64  * preferences, creating {@link PreferenceController} instances from the metadata, and
65  * associating the preferences with their corresponding controllers.
66  *
67  * <p>{@code preferenceTheme} must be specified in the application theme, and the parent to which
68  * this fragment attaches must implement {@link UxRestrictionsProvider} and
69  * {@link FragmentController} or an {@link IllegalStateException} will be thrown during
70  * {@link #onAttach(Context)}. Changes to driving state restrictions are propagated to
71  * controllers.
72  */
73 public abstract class SettingsFragment extends PreferenceFragment implements
74         CarUxRestrictionsManager.OnUxRestrictionsChangedListener, FragmentController, Indexable {
75 
76     @VisibleForTesting
77     static final String DIALOG_FRAGMENT_TAG =
78             "com.android.car.settings.common.SettingsFragment.DIALOG";
79 
80     private static final int MAX_NUM_PENDING_ACTIVITY_RESULT_CALLBACKS = 0xff - 1;
81 
82     private final Map<String, PreferenceController> mPreferenceControllersLookup = new ArrayMap<>();
83     private final List<PreferenceController> mPreferenceControllers = new ArrayList<>();
84     private final SparseArray<ActivityResultCallback> mActivityResultCallbackMap =
85             new SparseArray<>();
86 
87     private CarUxRestrictions mUxRestrictions;
88     private HighlightablePreferenceGroupAdapter mAdapter;
89     private int mCurrentRequestIndex = 0;
90 
91     /**
92      * Returns the resource id for the preference XML of this fragment.
93      */
94     @XmlRes
getPreferenceScreenResId()95     protected abstract int getPreferenceScreenResId();
96 
getToolbar()97     protected ToolbarController getToolbar() {
98         return getFragmentHost().getToolbar();
99     }
100     /**
101      * Returns the MenuItems to display in the toolbar. Subclasses should override this to
102      * add additional buttons, switches, ect. to the toolbar.
103      */
getToolbarMenuItems()104     protected List<MenuItem> getToolbarMenuItems() {
105         return null;
106     }
107 
108     /**
109      * Returns the controller of the given {@code clazz} for the given {@code
110      * preferenceKeyResId}. Subclasses may use this method in {@link #onAttach(Context)} to call
111      * setters on controllers to pass additional arguments after construction.
112      *
113      * <p>For example:
114      * <pre>{@code
115      * @Override
116      * public void onAttach(Context context) {
117      *     super.onAttach(context);
118      *     use(MyPreferenceController.class, R.string.pk_my_key).setMyArg(myArg);
119      * }
120      * }</pre>
121      *
122      * <p>Important: Use judiciously to minimize tight coupling between controllers and fragments.
123      */
124     @SuppressWarnings("unchecked") // PreferenceKey is the map key
use(Class<T> clazz, @StringRes int preferenceKeyResId)125     protected <T extends PreferenceController> T use(Class<T> clazz,
126             @StringRes int preferenceKeyResId) {
127         String preferenceKey = getString(preferenceKeyResId);
128         return (T) mPreferenceControllersLookup.get(preferenceKey);
129     }
130 
131     /**
132      * Enables rotary scrolling for the {@link CarUiRecyclerView} in this fragment.
133      * <p>
134      * Rotary scrolling should be enabled for scrolling views which contain content which the user
135      * may want to see but can't interact with, either alone or along with interactive (focusable)
136      * content.
137      */
enableRotaryScroll()138     protected void enableRotaryScroll() {
139         CarUiRecyclerView recyclerView = getView().findViewById(R.id.settings_recycler_view);
140         if (recyclerView != null) {
141             ViewUtils.setRotaryScrollEnabled(recyclerView.getView(), /* isVertical= */ true);
142         }
143     }
144 
145     @Override
onAttach(Context context)146     public void onAttach(Context context) {
147         super.onAttach(context);
148         if (!(getActivity() instanceof UxRestrictionsProvider)) {
149             throw new IllegalStateException("Must attach to a UxRestrictionsProvider");
150         }
151         if (!(getActivity() instanceof FragmentHost)) {
152             throw new IllegalStateException("Must attach to a FragmentHost");
153         }
154 
155         TypedValue tv = new TypedValue();
156         getActivity().getTheme().resolveAttribute(androidx.preference.R.attr.preferenceTheme, tv,
157                 true);
158         int theme = tv.resourceId;
159         if (theme == 0) {
160             throw new IllegalStateException("Must specify preferenceTheme in theme");
161         }
162         // Construct a context with the theme as controllers may create new preferences.
163         Context styledContext = new ContextThemeWrapper(getActivity(), theme);
164 
165         mUxRestrictions = ((UxRestrictionsProvider) requireActivity()).getCarUxRestrictions();
166         mPreferenceControllers.clear();
167         mPreferenceControllers.addAll(
168                 PreferenceControllerListHelper.getPreferenceControllersFromXml(styledContext,
169                         getPreferenceScreenResId(), /* fragmentController= */ this,
170                         mUxRestrictions));
171 
172         Lifecycle lifecycle = getLifecycle();
173         mPreferenceControllers.forEach(controller -> {
174             lifecycle.addObserver(controller);
175             mPreferenceControllersLookup.put(controller.getPreferenceKey(), controller);
176         });
177     }
178 
179     /**
180      * Inflates the preferences from {@link #getPreferenceScreenResId()} and associates the
181      * preference with their corresponding {@link PreferenceController} instances.
182      */
183     @Override
onCreatePreferences(Bundle savedInstanceState, String rootKey)184     public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
185         @XmlRes int resId = getPreferenceScreenResId();
186         if (resId <= 0) {
187             throw new IllegalStateException(
188                     "Fragment must specify a preference screen resource ID");
189         }
190         addPreferencesFromResource(resId);
191         PreferenceScreen screen = getPreferenceScreen();
192         for (PreferenceController controller : mPreferenceControllers) {
193             Preference pref = screen.findPreference(controller.getPreferenceKey());
194 
195             controller.setPreference(pref);
196         }
197     }
198 
199     @Override
onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)200     public CarUiRecyclerView onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent,
201             Bundle savedInstanceState) {
202         inflater.inflate(R.layout.settings_recyclerview_default, parent, /* attachToRoot= */ true);
203         return parent.findViewById(R.id.settings_recycler_view);
204     }
205 
206     @Override
setupToolbar(@onNull ToolbarController toolbar)207     protected void setupToolbar(@NonNull ToolbarController toolbar) {
208         List<MenuItem> items = getToolbarMenuItems();
209         if (items != null) {
210             if (items.size() == 1) {
211                 items.get(0).setId(R.id.toolbar_menu_item_0);
212             } else if (items.size() == 2) {
213                 items.get(0).setId(R.id.toolbar_menu_item_0);
214                 items.get(1).setId(R.id.toolbar_menu_item_1);
215             }
216         }
217         toolbar.setTitle(getPreferenceScreen().getTitle());
218         toolbar.setMenuItems(items);
219         toolbar.setLogo(null);
220         if (getActivity().getIntent().getBooleanExtra(META_DATA_KEY_SINGLE_PANE, false)) {
221             toolbar.setNavButtonMode(NavButtonMode.BACK);
222         }
223     }
224 
225     @Override
onDetach()226     public void onDetach() {
227         super.onDetach();
228         Lifecycle lifecycle = getLifecycle();
229         mPreferenceControllers.forEach(lifecycle::removeObserver);
230         mActivityResultCallbackMap.clear();
231     }
232 
233     @Override
onCreateAdapter(PreferenceScreen preferenceScreen)234     protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) {
235         mAdapter = createHighlightableAdapter(preferenceScreen);
236         return mAdapter;
237     }
238 
239     /**
240      * Returns a HighlightablePreferenceGroupAdapter to be used as the RecyclerView.Adapter for
241      * this fragment. Subclasses can override this method to return their own
242      * HighlightablePreferenceGroupAdapter instance.
243      */
createHighlightableAdapter( PreferenceScreen preferenceScreen)244     protected HighlightablePreferenceGroupAdapter createHighlightableAdapter(
245             PreferenceScreen preferenceScreen) {
246         return new HighlightablePreferenceGroupAdapter(preferenceScreen);
247     }
248 
requestPreferenceHighlight(String key)249     protected void requestPreferenceHighlight(String key) {
250         if (mAdapter != null) {
251             mAdapter.requestHighlight(getView(), getListView(), key);
252         }
253     }
254 
clearPreferenceHighlight()255     protected void clearPreferenceHighlight() {
256         if (mAdapter != null) {
257             mAdapter.clearHighlight(getView());
258         }
259     }
260 
261     /**
262      * Notifies {@link PreferenceController} instances of changes to {@link CarUxRestrictions}.
263      */
264     @Override
onUxRestrictionsChanged(CarUxRestrictions uxRestrictions)265     public void onUxRestrictionsChanged(CarUxRestrictions uxRestrictions) {
266         if (!uxRestrictions.isSameRestrictions(mUxRestrictions)) {
267             mUxRestrictions = uxRestrictions;
268             for (PreferenceController controller : mPreferenceControllers) {
269                 controller.onUxRestrictionsChanged(uxRestrictions);
270             }
271         }
272     }
273 
274     /**
275      * {@inheritDoc}
276      *
277      * <p>Settings needs to launch custom dialog types in order to extend the Device Default theme.
278      *
279      * @param preference The Preference object requesting the dialog.
280      */
281     @Override
onDisplayPreferenceDialog(Preference preference)282     public void onDisplayPreferenceDialog(Preference preference) {
283         // check if dialog is already showing
284         if (findDialogByTag(DIALOG_FRAGMENT_TAG) != null) {
285             return;
286         }
287 
288         if (preference instanceof ValidatedEditTextPreference) {
289             DialogFragment dialogFragment = preference instanceof PasswordEditTextPreference
290                     ? PasswordEditTextPreferenceDialogFragment.newInstance(preference.getKey())
291                     : ValidatedEditTextPreferenceDialogFragment.newInstance(preference.getKey());
292 
293             dialogFragment.setTargetFragment(/* fragment= */ this, /* requestCode= */ 0);
294             showDialog(dialogFragment, DIALOG_FRAGMENT_TAG);
295         } else {
296             super.onDisplayPreferenceDialog(preference);
297         }
298     }
299 
300     @Override
launchFragment(Fragment fragment)301     public void launchFragment(Fragment fragment) {
302         getFragmentHost().launchFragment(fragment);
303     }
304 
305     @Override
goBack()306     public void goBack() {
307         getFragmentHost().goBack();
308     }
309 
310     @Override
showDialog(DialogFragment dialogFragment, @Nullable String tag)311     public void showDialog(DialogFragment dialogFragment, @Nullable String tag) {
312         dialogFragment.show(getFragmentManager(), tag);
313     }
314 
315     @Override
showProgressBar(boolean visible)316     public void showProgressBar(boolean visible) {
317         if (getToolbar() != null && getToolbar().getProgressBar() != null) {
318             getToolbar().getProgressBar().setVisible(visible);
319         }
320     }
321 
322     @Nullable
323     @Override
findDialogByTag(String tag)324     public DialogFragment findDialogByTag(String tag) {
325         Fragment fragment = getFragmentManager().findFragmentByTag(tag);
326         if (fragment instanceof DialogFragment) {
327             return (DialogFragment) fragment;
328         }
329         return null;
330     }
331 
332     @NonNull
333     @Override
getSettingsLifecycle()334     public Lifecycle getSettingsLifecycle() {
335         return getLifecycle();
336     }
337 
338     @Override
startActivityForResult(Intent intent, int requestCode, ActivityResultCallback callback)339     public void startActivityForResult(Intent intent, int requestCode,
340             ActivityResultCallback callback) {
341         validateRequestCodeForPreferenceController(requestCode);
342         int requestIndex = allocateRequestIndex(callback);
343         super.startActivityForResult(intent, ((requestIndex + 1) << 8) + (requestCode & 0xff));
344     }
345 
346     @Override
startIntentSenderForResult(IntentSender intent, int requestCode, @Nullable Intent fillInIntent, int flagsMask, int flagsValues, Bundle options, ActivityResultCallback callback)347     public void startIntentSenderForResult(IntentSender intent, int requestCode,
348             @Nullable Intent fillInIntent, int flagsMask, int flagsValues, Bundle options,
349             ActivityResultCallback callback)
350             throws IntentSender.SendIntentException {
351         validateRequestCodeForPreferenceController(requestCode);
352         int requestIndex = allocateRequestIndex(callback);
353         super.startIntentSenderForResult(intent, ((requestIndex + 1) << 8) + (requestCode & 0xff),
354                 fillInIntent, flagsMask, flagsValues, /* extraFlags= */ 0, options);
355     }
356 
357     @Override
onActivityResult(int requestCode, int resultCode, Intent data)358     public void onActivityResult(int requestCode, int resultCode, Intent data) {
359         super.onActivityResult(requestCode, resultCode, data);
360         int requestIndex = (requestCode >> 8) & 0xff;
361         if (requestIndex != 0) {
362             requestIndex--;
363             ActivityResultCallback callback = mActivityResultCallbackMap.get(requestIndex);
364             mActivityResultCallbackMap.remove(requestIndex);
365             if (callback != null) {
366                 callback.processActivityResult(requestCode & 0xff, resultCode, data);
367             }
368         }
369     }
370 
371     @Override
getPreferenceToolbar(@onNull Fragment fragment)372     protected ToolbarController getPreferenceToolbar(@NonNull Fragment fragment) {
373         return getToolbar();
374     }
375 
376     @Override
getPreferenceInsets(@onNull Fragment fragment)377     protected Insets getPreferenceInsets(@NonNull Fragment fragment) {
378         return null;
379     }
380 
381     // Allocates the next available startActivityForResult request index.
allocateRequestIndex(ActivityResultCallback callback)382     private int allocateRequestIndex(ActivityResultCallback callback) {
383         // Check that we haven't exhausted the request index space.
384         if (mActivityResultCallbackMap.size() >= MAX_NUM_PENDING_ACTIVITY_RESULT_CALLBACKS) {
385             throw new IllegalStateException(
386                     "Too many pending activity result callbacks.");
387         }
388 
389         // Find an unallocated request index in the mPendingFragmentActivityResults map.
390         while (mActivityResultCallbackMap.indexOfKey(mCurrentRequestIndex) >= 0) {
391             mCurrentRequestIndex =
392                     (mCurrentRequestIndex + 1) % MAX_NUM_PENDING_ACTIVITY_RESULT_CALLBACKS;
393         }
394 
395         mActivityResultCallbackMap.put(mCurrentRequestIndex, callback);
396         return mCurrentRequestIndex;
397     }
398 
399     /**
400      * Checks whether the given request code is a valid code by masking it with 0xff00. Throws an
401      * {@link IllegalArgumentException} if the code is not valid.
402      */
validateRequestCodeForPreferenceController(int requestCode)403     private static void validateRequestCodeForPreferenceController(int requestCode) {
404         if ((requestCode & 0xff00) != 0) {
405             throw new IllegalArgumentException("Can only use lower 8 bits for requestCode");
406         }
407     }
408 
getFragmentHost()409     private FragmentHost getFragmentHost() {
410         return (FragmentHost) requireActivity();
411     }
412 }
413