1 /*
2  * Copyright (C) 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.settings.common;
18 
19 import static android.view.View.GONE;
20 import static android.view.ViewGroup.FOCUS_BEFORE_DESCENDANTS;
21 import static android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS;
22 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
23 
24 import android.car.drivingstate.CarUxRestrictions;
25 import android.car.drivingstate.CarUxRestrictionsManager;
26 import android.car.drivingstate.CarUxRestrictionsManager.OnUxRestrictionsChangedListener;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.content.pm.ActivityInfo;
30 import android.content.pm.PackageManager;
31 import android.os.Bundle;
32 import android.provider.Settings;
33 import android.view.View;
34 import android.view.ViewGroup;
35 import android.view.ViewTreeObserver;
36 import android.view.inputmethod.InputMethodManager;
37 import android.widget.Toast;
38 
39 import androidx.annotation.NonNull;
40 import androidx.annotation.Nullable;
41 import androidx.fragment.app.DialogFragment;
42 import androidx.fragment.app.Fragment;
43 import androidx.fragment.app.FragmentActivity;
44 import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
45 import androidx.preference.Preference;
46 import androidx.preference.PreferenceFragmentCompat;
47 
48 import com.android.car.apps.common.util.Themes;
49 import com.android.car.settings.R;
50 import com.android.car.settings.common.rotary.SettingsFocusParkingView;
51 import com.android.car.ui.baselayout.Insets;
52 import com.android.car.ui.baselayout.InsetsChangedListener;
53 import com.android.car.ui.core.CarUi;
54 import com.android.car.ui.toolbar.MenuItem;
55 import com.android.car.ui.toolbar.NavButtonMode;
56 import com.android.car.ui.toolbar.ToolbarController;
57 import com.android.settingslib.core.lifecycle.HideNonSystemOverlayMixin;
58 
59 import java.util.Collections;
60 import java.util.List;
61 
62 /**
63  * Base activity class for car settings, provides a action bar with a back button that goes to
64  * previous activity.
65  */
66 public abstract class BaseCarSettingsActivity extends FragmentActivity implements
67         FragmentHost, OnUxRestrictionsChangedListener, UxRestrictionsProvider,
68         OnBackStackChangedListener, PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
69         InsetsChangedListener {
70 
71     /**
72      * Meta data key for specifying the preference key of the top level menu preference that the
73      * initial activity's fragment falls under. If this is not specified in the activity's
74      * metadata, the top level menu preference will not be highlighted upon activity launch.
75      */
76     public static final String META_DATA_KEY_HEADER_KEY =
77             "com.android.car.settings.TOP_LEVEL_HEADER_KEY";
78 
79     /**
80      * Meta data key for specifying activities that should always be shown in the single pane
81      * configuration. If not specified for the activity, the activity will default to the value
82      * {@link R.bool.config_global_force_single_pane}.
83      */
84     public static final String META_DATA_KEY_SINGLE_PANE = "com.android.car.settings.SINGLE_PANE";
85 
86     private static final Logger LOG = new Logger(BaseCarSettingsActivity.class);
87     private static final int SEARCH_REQUEST_CODE = 501;
88     private static final String KEY_HAS_NEW_INTENT = "key_has_new_intent";
89 
90     private boolean mHasNewIntent = true;
91     private boolean mHasInitialFocus = false;
92     private boolean mIsInitialFragmentTransaction = true;
93 
94     private String mTopLevelHeaderKey;
95     private boolean mIsSinglePane;
96 
97     private ToolbarController mGlobalToolbar;
98     private ToolbarController mMiniToolbar;
99 
100     private CarUxRestrictionsHelper mUxRestrictionsHelper;
101     private ViewGroup mFragmentContainer;
102     private View mRestrictedMessage;
103     // Default to minimum restriction.
104     private CarUxRestrictions mCarUxRestrictions = new CarUxRestrictions.Builder(
105             /* reqOpt= */ true,
106             CarUxRestrictions.UX_RESTRICTIONS_BASELINE,
107             /* timestamp= */ 0
108     ).build();
109 
110     private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener;
111     private final ViewTreeObserver.OnGlobalFocusChangeListener mFocusChangeListener =
112             (oldFocus, newFocus) -> {
113                 if (oldFocus instanceof SettingsFocusParkingView) {
114                     // Focus is manually shifted away from the SettingsFocusParkingView.
115                     // Therefore, the focus should no longer shift upon global layout.
116                     removeGlobalLayoutListener();
117                 }
118                 if (newFocus instanceof SettingsFocusParkingView && mGlobalLayoutListener == null) {
119                     // Attempting to shift focus to the SettingsFocusParkingView without a layout
120                     // listener is not allowed, since it can cause undermined focus behavior
121                     // in these rare edge cases.
122                     requestTopLevelMenuFocus();
123                 }
124 
125                 // This will maintain focus in the content pane if a view goes from
126                 // focusable -> unfocusable.
127                 if (oldFocus == null && mHasInitialFocus) {
128                     requestContentPaneFocus();
129                 } else {
130                     mHasInitialFocus = true;
131                 }
132             };
133 
134     @Override
onCreate(Bundle savedInstanceState)135     protected void onCreate(Bundle savedInstanceState) {
136         super.onCreate(savedInstanceState);
137         getLifecycle().addObserver(new HideNonSystemOverlayMixin(this));
138         if (savedInstanceState != null) {
139             mHasNewIntent = savedInstanceState.getBoolean(KEY_HAS_NEW_INTENT, mHasNewIntent);
140         }
141         populateMetaData();
142         setContentView(R.layout.car_setting_activity);
143         mFragmentContainer = findViewById(R.id.fragment_container);
144 
145         // We do this so that the insets are not automatically sent to the fragments.
146         // The fragments have their own insets handled by the installBaseLayoutAround() method.
147         CarUi.replaceInsetsChangedListenerWith(this, this);
148 
149         setUpToolbars();
150         getSupportFragmentManager().addOnBackStackChangedListener(this);
151         mRestrictedMessage = findViewById(R.id.restricted_message);
152 
153         if (mHasNewIntent) {
154             launchIfDifferent(getInitialFragment());
155             mHasNewIntent = false;
156         } else if (!mIsSinglePane) {
157             updateMiniToolbarState();
158         }
159         mUxRestrictionsHelper = new CarUxRestrictionsHelper(/* context= */ this, /* listener= */
160                 this);
161 
162         if (shouldFocusContentOnLaunch()) {
163             requestContentPaneFocus();
164             mHasInitialFocus = true;
165         } else {
166             requestTopLevelMenuFocus();
167         }
168         setUpFocusChangeListener(true);
169         hideFocusParkingViewIfNeeded();
170     }
171 
172     @Override
onSaveInstanceState(@onNull Bundle outState)173     protected void onSaveInstanceState(@NonNull Bundle outState) {
174         super.onSaveInstanceState(outState);
175         outState.putBoolean(KEY_HAS_NEW_INTENT, mHasNewIntent);
176     }
177 
178     @Override
onDestroy()179     public void onDestroy() {
180         setUpFocusChangeListener(false);
181         removeGlobalLayoutListener();
182         mUxRestrictionsHelper.destroy();
183         mUxRestrictionsHelper = null;
184         super.onDestroy();
185     }
186 
187     @Override
onBackPressed()188     public void onBackPressed() {
189         super.onBackPressed();
190         hideKeyboard();
191         // If the backstack is empty, finish the activity.
192         if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
193             finish();
194         }
195     }
196 
197     @Override
getIntent()198     public Intent getIntent() {
199         Intent superIntent = super.getIntent();
200         if (mTopLevelHeaderKey != null) {
201             superIntent.putExtra(META_DATA_KEY_HEADER_KEY, mTopLevelHeaderKey);
202         }
203         superIntent.putExtra(META_DATA_KEY_SINGLE_PANE, mIsSinglePane);
204         return superIntent;
205     }
206 
207     @Override
launchFragment(Fragment fragment)208     public void launchFragment(Fragment fragment) {
209         if (fragment instanceof DialogFragment) {
210             throw new IllegalArgumentException(
211                     "cannot launch dialogs with launchFragment() - use showDialog() instead");
212         }
213 
214         if (mIsSinglePane) {
215             Intent intent = SubSettingsActivity.newInstance(/* context= */ this, fragment);
216             startActivity(intent);
217         } else {
218             launchFragmentInternal(fragment);
219         }
220     }
221 
launchFragmentInternal(Fragment fragment)222     protected void launchFragmentInternal(Fragment fragment) {
223         getSupportFragmentManager()
224                 .beginTransaction()
225                 .setCustomAnimations(
226                         Themes.getAttrResourceId(/* context= */ this,
227                                 android.R.attr.fragmentOpenEnterAnimation),
228                         Themes.getAttrResourceId(/* context= */ this,
229                                 android.R.attr.fragmentOpenExitAnimation),
230                         Themes.getAttrResourceId(/* context= */ this,
231                                 android.R.attr.fragmentCloseEnterAnimation),
232                         Themes.getAttrResourceId(/* context= */ this,
233                                 android.R.attr.fragmentCloseExitAnimation))
234                 .replace(R.id.fragment_container, fragment,
235                         Integer.toString(getSupportFragmentManager().getBackStackEntryCount()))
236                 .addToBackStack(null)
237                 .commit();
238     }
239 
240     @Override
goBack()241     public void goBack() {
242         onBackPressed();
243     }
244 
245     @Override
showBlockingMessage()246     public void showBlockingMessage() {
247         Toast.makeText(this, R.string.restricted_while_driving, Toast.LENGTH_SHORT).show();
248     }
249 
250     @Override
getToolbar()251     public ToolbarController getToolbar() {
252         if (mIsSinglePane) {
253             return mGlobalToolbar;
254         }
255         return mMiniToolbar;
256     }
257 
258     @Override
onUxRestrictionsChanged(CarUxRestrictions restrictionInfo)259     public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) {
260         mCarUxRestrictions = restrictionInfo;
261 
262         // Update restrictions for current fragment.
263         Fragment currentFragment = getCurrentFragment();
264         if (currentFragment instanceof OnUxRestrictionsChangedListener) {
265             ((OnUxRestrictionsChangedListener) currentFragment)
266                     .onUxRestrictionsChanged(restrictionInfo);
267         }
268         updateBlockingView(currentFragment);
269 
270         if (!mIsSinglePane) {
271             // Update restrictions for top level menu (if present).
272             Fragment topLevelMenu =
273                     getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
274             if (topLevelMenu instanceof CarUxRestrictionsManager.OnUxRestrictionsChangedListener) {
275                 ((CarUxRestrictionsManager.OnUxRestrictionsChangedListener) topLevelMenu)
276                         .onUxRestrictionsChanged(restrictionInfo);
277             }
278         }
279     }
280 
281     @Override
getCarUxRestrictions()282     public CarUxRestrictions getCarUxRestrictions() {
283         return mCarUxRestrictions;
284     }
285 
286     @Override
onBackStackChanged()287     public void onBackStackChanged() {
288         onUxRestrictionsChanged(getCarUxRestrictions());
289         if (!mIsSinglePane) {
290             if (mHasInitialFocus && shouldFocusContentOnBackstackChange()) {
291                 requestContentPaneFocus();
292             }
293             updateMiniToolbarState();
294         }
295     }
296 
297     @Override
onCarUiInsetsChanged(Insets insets)298     public void onCarUiInsetsChanged(Insets insets) {
299         // intentional no-op - insets are handled by the listeners created during toolbar setup
300     }
301 
302     @Override
onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref)303     public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) {
304         if (pref.getFragment() != null) {
305             Fragment fragment = Fragment.instantiate(/* context= */ this, pref.getFragment(),
306                     pref.getExtras());
307             launchFragment(fragment);
308             return true;
309         }
310         return false;
311     }
312 
313     /**
314      * Gets the fragment to show onCreate. If null, the activity will not perform an initial
315      * fragment transaction.
316      */
317     @Nullable
getInitialFragment()318     protected abstract Fragment getInitialFragment();
319 
getCurrentFragment()320     protected Fragment getCurrentFragment() {
321         return getSupportFragmentManager().findFragmentById(R.id.fragment_container);
322     }
323 
324     /**
325      * Returns whether the content pane should get focus initially when in dual-pane configuration.
326      */
shouldFocusContentOnLaunch()327     protected boolean shouldFocusContentOnLaunch() {
328         return true;
329     }
330 
launchIfDifferent(Fragment newFragment)331     private void launchIfDifferent(Fragment newFragment) {
332         Fragment currentFragment = getCurrentFragment();
333         if ((newFragment != null) && differentFragment(newFragment, currentFragment)) {
334             LOG.d("launchIfDifferent: " + newFragment + " replacing " + currentFragment);
335             launchFragmentInternal(newFragment);
336         }
337     }
338 
339     /**
340      * Returns {code true} if newFragment is different from current fragment.
341      */
differentFragment(Fragment newFragment, Fragment currentFragment)342     private boolean differentFragment(Fragment newFragment, Fragment currentFragment) {
343         return (currentFragment == null)
344                 || (!currentFragment.getClass().equals(newFragment.getClass()));
345     }
346 
hideKeyboard()347     private void hideKeyboard() {
348         InputMethodManager imm = (InputMethodManager) this.getSystemService(
349                 Context.INPUT_METHOD_SERVICE);
350         imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0);
351     }
352 
updateBlockingView(@ullable Fragment currentFragment)353     private void updateBlockingView(@Nullable Fragment currentFragment) {
354         if (mRestrictedMessage == null) {
355             return;
356         }
357         if (currentFragment instanceof BaseFragment
358                 && !((BaseFragment) currentFragment).canBeShown(mCarUxRestrictions)) {
359             mRestrictedMessage.setVisibility(View.VISIBLE);
360             mFragmentContainer.setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
361             mFragmentContainer.clearFocus();
362             hideKeyboard();
363         } else {
364             mRestrictedMessage.setVisibility(View.GONE);
365             mFragmentContainer.setDescendantFocusability(FOCUS_BEFORE_DESCENDANTS);
366         }
367     }
368 
populateMetaData()369     private void populateMetaData() {
370         try {
371             ActivityInfo ai = getPackageManager().getActivityInfo(getComponentName(),
372                     PackageManager.GET_META_DATA);
373             if (ai == null || ai.metaData == null) {
374                 mIsSinglePane = getResources().getBoolean(R.bool.config_global_force_single_pane);
375                 return;
376             }
377             mTopLevelHeaderKey = ai.metaData.getString(META_DATA_KEY_HEADER_KEY);
378             mIsSinglePane = ai.metaData.getBoolean(META_DATA_KEY_SINGLE_PANE,
379                     getResources().getBoolean(R.bool.config_global_force_single_pane));
380         } catch (PackageManager.NameNotFoundException e) {
381             LOG.w("Unable to find package", e);
382         }
383     }
384 
setUpToolbars()385     private void setUpToolbars() {
386         View globalToolbarWrappedView = mIsSinglePane ? findViewById(
387                 R.id.fragment_container_wrapper) : findViewById(R.id.top_level_menu_container);
388         mGlobalToolbar = CarUi.installBaseLayoutAround(
389                 globalToolbarWrappedView,
390                 insets -> globalToolbarWrappedView.setPadding(
391                         insets.getLeft(), insets.getTop(), insets.getRight(),
392                         insets.getBottom()), /* hasToolbar= */ true);
393         if (mIsSinglePane) {
394             mGlobalToolbar.setNavButtonMode(NavButtonMode.BACK);
395             findViewById(R.id.top_level_menu_container).setVisibility(View.GONE);
396             findViewById(R.id.top_level_divider).setVisibility(View.GONE);
397             return;
398         }
399         mMiniToolbar = CarUi.installBaseLayoutAround(
400                 findViewById(R.id.fragment_container_wrapper),
401                 insets -> findViewById(R.id.fragment_container_wrapper).setPadding(
402                         insets.getLeft(), insets.getTop(), insets.getRight(),
403                         insets.getBottom()), /* hasToolbar= */ true);
404 
405         MenuItem searchButton = new MenuItem.Builder(this)
406                 .setToSearch()
407                 .setOnClickListener(i -> onSearchButtonClicked())
408                 .setUxRestrictions(CarUxRestrictions.UX_RESTRICTIONS_NO_KEYBOARD)
409                 .setId(R.id.toolbar_menu_item_0)
410                 .build();
411         List<MenuItem> items = Collections.singletonList(searchButton);
412 
413         mGlobalToolbar.setTitle(R.string.settings_label);
414         mGlobalToolbar.setNavButtonMode(NavButtonMode.DISABLED);
415         mGlobalToolbar.setLogo(R.drawable.ic_launcher_settings);
416         mGlobalToolbar.setMenuItems(items);
417     }
418 
updateMiniToolbarState()419     private void updateMiniToolbarState() {
420         if (mMiniToolbar == null) {
421             return;
422         }
423         if (getSupportFragmentManager().getBackStackEntryCount() > 1 || !isTaskRoot()) {
424             mMiniToolbar.setNavButtonMode(NavButtonMode.BACK);
425         } else {
426             mMiniToolbar.setNavButtonMode(NavButtonMode.DISABLED);
427         }
428     }
429 
hideFocusParkingViewIfNeeded()430     private void hideFocusParkingViewIfNeeded() {
431         if (mIsSinglePane) {
432             findViewById(R.id.settings_focus_parking_view).setVisibility(GONE);
433         }
434     }
435 
setUpFocusChangeListener(boolean enable)436     private void setUpFocusChangeListener(boolean enable) {
437         if (mIsSinglePane) {
438             // The focus change listener is only needed with two panes.
439             return;
440         }
441         ViewTreeObserver observer = findViewById(
442                 R.id.car_settings_activity_wrapper).getViewTreeObserver();
443         if (enable) {
444             observer.addOnGlobalFocusChangeListener(mFocusChangeListener);
445         } else {
446             observer.removeOnGlobalFocusChangeListener(mFocusChangeListener);
447         }
448     }
449 
requestTopLevelMenuFocus()450     private void requestTopLevelMenuFocus() {
451         if (mIsSinglePane) {
452             return;
453         }
454         Fragment topLevelMenu = getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
455         if (topLevelMenu == null) {
456             return;
457         }
458         View fragmentView = topLevelMenu.getView();
459         if (fragmentView == null) {
460             return;
461         }
462         View focusArea = fragmentView.findViewById(R.id.settings_car_ui_focus_area);
463         if (focusArea == null) {
464             return;
465         }
466         removeGlobalLayoutListener();
467         mGlobalLayoutListener = () -> {
468             if (focusArea.isInTouchMode() || focusArea.hasFocus()) {
469                 return;
470             }
471             focusArea.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
472             removeGlobalLayoutListener();
473         };
474         fragmentView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
475     }
476 
requestContentPaneFocus()477     private void requestContentPaneFocus() {
478         if (mIsSinglePane) {
479             return;
480         }
481         if (getCurrentFragment() == null) {
482             return;
483         }
484         View fragmentView = getCurrentFragment().getView();
485         if (fragmentView == null) {
486             return;
487         }
488         removeGlobalLayoutListener();
489         if (fragmentView.isInTouchMode()) {
490             mHasInitialFocus = false;
491             return;
492         }
493         View focusArea = fragmentView.findViewById(R.id.settings_car_ui_focus_area);
494 
495         if (focusArea == null) {
496             focusArea = fragmentView.findViewById(R.id.settings_content_focus_area);
497             if (focusArea == null) {
498                 return;
499             }
500         }
501         removeGlobalLayoutListener();
502         View finalFocusArea = focusArea; // required to be effectively final for inner class access
503         mGlobalLayoutListener = () -> {
504             if (finalFocusArea.isInTouchMode() || finalFocusArea.hasFocus()) {
505                 return;
506             }
507             boolean success = finalFocusArea.performAccessibilityAction(
508                     ACTION_FOCUS, /* arguments= */ null);
509             if (success) {
510                 removeGlobalLayoutListener();
511             } else {
512                 findViewById(
513                         R.id.settings_focus_parking_view).performAccessibilityAction(
514                         ACTION_FOCUS, /* arguments= */ null);
515             }
516         };
517         fragmentView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
518     }
519 
shouldFocusContentOnBackstackChange()520     private boolean shouldFocusContentOnBackstackChange() {
521         // We don't want to reset mHasInitialFocus when initial fragment is added
522         if (mIsInitialFragmentTransaction && getInitialFragment() != null) {
523             mIsInitialFragmentTransaction = false;
524             return false;
525         }
526 
527         return true;
528     }
529 
removeGlobalLayoutListener()530     private void removeGlobalLayoutListener() {
531         if (mGlobalLayoutListener == null) {
532             return;
533         }
534 
535         // Check content pane
536         Fragment contentFragment = getCurrentFragment();
537         if (contentFragment != null && contentFragment.getView() != null) {
538             contentFragment.getView().getViewTreeObserver()
539                     .removeOnGlobalLayoutListener(mGlobalLayoutListener);
540         }
541 
542         // Check top level menu
543         Fragment topLevelMenu = getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
544         if (topLevelMenu != null && topLevelMenu.getView() != null) {
545             topLevelMenu.getView().getViewTreeObserver()
546                     .removeOnGlobalLayoutListener(mGlobalLayoutListener);
547         }
548 
549         mGlobalLayoutListener = null;
550     }
551 
onSearchButtonClicked()552     private void onSearchButtonClicked() {
553         Intent intent = new Intent(Settings.ACTION_APP_SEARCH_SETTINGS)
554                 .setPackage(getSettingsIntelligencePkgName());
555         if (intent.resolveActivity(getPackageManager()) == null) {
556             return;
557         }
558         startActivityForResult(intent, SEARCH_REQUEST_CODE);
559     }
560 
getSettingsIntelligencePkgName()561     private String getSettingsIntelligencePkgName() {
562         return getString(R.string.config_settingsintelligence_package_name);
563     }
564 }
565