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