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