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