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 newFocus.clearFocus(); 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 } 164 setUpFocusChangeListener(true); 165 } 166 167 @Override onSaveInstanceState(@onNull Bundle outState)168 protected void onSaveInstanceState(@NonNull Bundle outState) { 169 super.onSaveInstanceState(outState); 170 outState.putBoolean(KEY_HAS_NEW_INTENT, mHasNewIntent); 171 } 172 173 @Override onDestroy()174 public void onDestroy() { 175 setUpFocusChangeListener(false); 176 removeGlobalLayoutListener(); 177 mUxRestrictionsHelper.destroy(); 178 mUxRestrictionsHelper = null; 179 super.onDestroy(); 180 } 181 182 @Override onBackPressed()183 public void onBackPressed() { 184 super.onBackPressed(); 185 hideKeyboard(); 186 // If the backstack is empty, finish the activity. 187 if (getSupportFragmentManager().getBackStackEntryCount() == 0) { 188 finish(); 189 } 190 } 191 192 @Override getIntent()193 public Intent getIntent() { 194 Intent superIntent = super.getIntent(); 195 if (mTopLevelHeaderKey != null) { 196 superIntent.putExtra(META_DATA_KEY_HEADER_KEY, mTopLevelHeaderKey); 197 } 198 superIntent.putExtra(META_DATA_KEY_SINGLE_PANE, mIsSinglePane); 199 return superIntent; 200 } 201 202 @Override launchFragment(Fragment fragment)203 public void launchFragment(Fragment fragment) { 204 if (fragment instanceof DialogFragment) { 205 throw new IllegalArgumentException( 206 "cannot launch dialogs with launchFragment() - use showDialog() instead"); 207 } 208 209 if (mIsSinglePane) { 210 Intent intent = SubSettingsActivity.newInstance(/* context= */ this, fragment); 211 startActivity(intent); 212 } else { 213 launchFragmentInternal(fragment); 214 } 215 } 216 launchFragmentInternal(Fragment fragment)217 private void launchFragmentInternal(Fragment fragment) { 218 getSupportFragmentManager() 219 .beginTransaction() 220 .setCustomAnimations( 221 Themes.getAttrResourceId(/* context= */ this, 222 android.R.attr.fragmentOpenEnterAnimation), 223 Themes.getAttrResourceId(/* context= */ this, 224 android.R.attr.fragmentOpenExitAnimation), 225 Themes.getAttrResourceId(/* context= */ this, 226 android.R.attr.fragmentCloseEnterAnimation), 227 Themes.getAttrResourceId(/* context= */ this, 228 android.R.attr.fragmentCloseExitAnimation)) 229 .replace(R.id.fragment_container, fragment, 230 Integer.toString(getSupportFragmentManager().getBackStackEntryCount())) 231 .addToBackStack(null) 232 .commit(); 233 } 234 235 @Override goBack()236 public void goBack() { 237 onBackPressed(); 238 } 239 240 @Override showBlockingMessage()241 public void showBlockingMessage() { 242 Toast.makeText(this, R.string.restricted_while_driving, Toast.LENGTH_SHORT).show(); 243 } 244 245 @Override getToolbar()246 public ToolbarController getToolbar() { 247 if (mIsSinglePane) { 248 return mGlobalToolbar; 249 } 250 return mMiniToolbar; 251 } 252 253 @Override onUxRestrictionsChanged(CarUxRestrictions restrictionInfo)254 public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) { 255 mCarUxRestrictions = restrictionInfo; 256 257 // Update restrictions for current fragment. 258 Fragment currentFragment = getCurrentFragment(); 259 if (currentFragment instanceof OnUxRestrictionsChangedListener) { 260 ((OnUxRestrictionsChangedListener) currentFragment) 261 .onUxRestrictionsChanged(restrictionInfo); 262 } 263 updateBlockingView(currentFragment); 264 265 if (!mIsSinglePane) { 266 // Update restrictions for top level menu (if present). 267 Fragment topLevelMenu = 268 getSupportFragmentManager().findFragmentById(R.id.top_level_menu); 269 if (topLevelMenu instanceof CarUxRestrictionsManager.OnUxRestrictionsChangedListener) { 270 ((CarUxRestrictionsManager.OnUxRestrictionsChangedListener) topLevelMenu) 271 .onUxRestrictionsChanged(restrictionInfo); 272 } 273 } 274 } 275 276 @Override getCarUxRestrictions()277 public CarUxRestrictions getCarUxRestrictions() { 278 return mCarUxRestrictions; 279 } 280 281 @Override onBackStackChanged()282 public void onBackStackChanged() { 283 onUxRestrictionsChanged(getCarUxRestrictions()); 284 if (!mIsSinglePane) { 285 if (mHasInitialFocus) { 286 requestContentPaneFocus(); 287 } 288 updateMiniToolbarState(); 289 } 290 } 291 292 @Override onCarUiInsetsChanged(Insets insets)293 public void onCarUiInsetsChanged(Insets insets) { 294 // intentional no-op - insets are handled by the listeners created during toolbar setup 295 } 296 297 @Override onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref)298 public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) { 299 if (pref.getFragment() != null) { 300 Fragment fragment = Fragment.instantiate(/* context= */ this, pref.getFragment(), 301 pref.getExtras()); 302 launchFragment(fragment); 303 return true; 304 } 305 return false; 306 } 307 308 /** 309 * Gets the fragment to show onCreate. If null, the activity will not perform an initial 310 * fragment transaction. 311 */ 312 @Nullable getInitialFragment()313 protected abstract Fragment getInitialFragment(); 314 getCurrentFragment()315 protected Fragment getCurrentFragment() { 316 return getSupportFragmentManager().findFragmentById(R.id.fragment_container); 317 } 318 319 /** 320 * Returns whether the content pane should get focus initially when in dual-pane configuration. 321 */ shouldFocusContentOnLaunch()322 protected boolean shouldFocusContentOnLaunch() { 323 return true; 324 } 325 launchIfDifferent(Fragment newFragment)326 private void launchIfDifferent(Fragment newFragment) { 327 Fragment currentFragment = getCurrentFragment(); 328 if ((newFragment != null) && differentFragment(newFragment, currentFragment)) { 329 LOG.d("launchIfDifferent: " + newFragment + " replacing " + currentFragment); 330 launchFragmentInternal(newFragment); 331 } 332 } 333 334 /** 335 * Returns {code true} if newFragment is different from current fragment. 336 */ differentFragment(Fragment newFragment, Fragment currentFragment)337 private boolean differentFragment(Fragment newFragment, Fragment currentFragment) { 338 return (currentFragment == null) 339 || (!currentFragment.getClass().equals(newFragment.getClass())); 340 } 341 hideKeyboard()342 private void hideKeyboard() { 343 InputMethodManager imm = (InputMethodManager) this.getSystemService( 344 Context.INPUT_METHOD_SERVICE); 345 imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0); 346 } 347 updateBlockingView(@ullable Fragment currentFragment)348 private void updateBlockingView(@Nullable Fragment currentFragment) { 349 if (mRestrictedMessage == null) { 350 return; 351 } 352 if (currentFragment instanceof BaseFragment 353 && !((BaseFragment) currentFragment).canBeShown(mCarUxRestrictions)) { 354 mRestrictedMessage.setVisibility(View.VISIBLE); 355 mFragmentContainer.setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS); 356 mFragmentContainer.clearFocus(); 357 hideKeyboard(); 358 } else { 359 mRestrictedMessage.setVisibility(View.GONE); 360 mFragmentContainer.setDescendantFocusability(FOCUS_BEFORE_DESCENDANTS); 361 } 362 } 363 populateMetaData()364 private void populateMetaData() { 365 try { 366 ActivityInfo ai = getPackageManager().getActivityInfo(getComponentName(), 367 PackageManager.GET_META_DATA); 368 if (ai == null || ai.metaData == null) { 369 mIsSinglePane = getResources().getBoolean(R.bool.config_global_force_single_pane); 370 return; 371 } 372 mTopLevelHeaderKey = ai.metaData.getString(META_DATA_KEY_HEADER_KEY); 373 mIsSinglePane = ai.metaData.getBoolean(META_DATA_KEY_SINGLE_PANE, 374 getResources().getBoolean(R.bool.config_global_force_single_pane)); 375 } catch (PackageManager.NameNotFoundException e) { 376 LOG.w("Unable to find package", e); 377 } 378 } 379 setUpToolbars()380 private void setUpToolbars() { 381 View globalToolbarWrappedView = mIsSinglePane ? findViewById( 382 R.id.fragment_container_wrapper) : findViewById(R.id.top_level_menu); 383 mGlobalToolbar = CarUi.installBaseLayoutAround( 384 globalToolbarWrappedView, 385 insets -> globalToolbarWrappedView.setPadding( 386 insets.getLeft(), insets.getTop(), insets.getRight(), 387 insets.getBottom()), /* hasToolbar= */ true); 388 if (mIsSinglePane) { 389 mGlobalToolbar.setNavButtonMode(NavButtonMode.BACK); 390 findViewById(R.id.top_level_menu_container).setVisibility(View.GONE); 391 findViewById(R.id.top_level_divider).setVisibility(View.GONE); 392 return; 393 } 394 mMiniToolbar = CarUi.installBaseLayoutAround( 395 findViewById(R.id.fragment_container_wrapper), 396 insets -> findViewById(R.id.fragment_container_wrapper).setPadding( 397 insets.getLeft(), insets.getTop(), insets.getRight(), 398 insets.getBottom()), /* hasToolbar= */ true); 399 400 MenuItem searchButton = new MenuItem.Builder(this) 401 .setToSearch() 402 .setOnClickListener(i -> onSearchButtonClicked()) 403 .setUxRestrictions(CarUxRestrictions.UX_RESTRICTIONS_NO_KEYBOARD) 404 .setId(R.id.toolbar_menu_item_0) 405 .build(); 406 List<MenuItem> items = Collections.singletonList(searchButton); 407 408 mGlobalToolbar.setTitle(R.string.settings_label); 409 mGlobalToolbar.setNavButtonMode(NavButtonMode.DISABLED); 410 mGlobalToolbar.setLogo(R.drawable.ic_launcher_settings); 411 mGlobalToolbar.setMenuItems(items); 412 } 413 updateMiniToolbarState()414 private void updateMiniToolbarState() { 415 if (mMiniToolbar == null) { 416 return; 417 } 418 if (getSupportFragmentManager().getBackStackEntryCount() > 1 || !isTaskRoot()) { 419 mMiniToolbar.setNavButtonMode(NavButtonMode.BACK); 420 } else { 421 mMiniToolbar.setNavButtonMode(NavButtonMode.DISABLED); 422 } 423 } 424 setUpFocusChangeListener(boolean enable)425 private void setUpFocusChangeListener(boolean enable) { 426 if (mIsSinglePane) { 427 // The focus change listener is only needed with two panes. 428 return; 429 } 430 ViewTreeObserver observer = findViewById( 431 R.id.car_settings_activity_wrapper).getViewTreeObserver(); 432 if (enable) { 433 observer.addOnGlobalFocusChangeListener(mFocusChangeListener); 434 } else { 435 observer.removeOnGlobalFocusChangeListener(mFocusChangeListener); 436 } 437 } 438 requestContentPaneFocus()439 private void requestContentPaneFocus() { 440 if (mIsSinglePane) { 441 return; 442 } 443 if (getCurrentFragment() == null) { 444 return; 445 } 446 View fragmentView = getCurrentFragment().getView(); 447 if (fragmentView == null) { 448 return; 449 } 450 removeGlobalLayoutListener(); 451 if (fragmentView.isInTouchMode()) { 452 mHasInitialFocus = false; 453 return; 454 } 455 View focusArea = fragmentView.findViewById(R.id.settings_car_ui_focus_area); 456 457 if (focusArea == null) { 458 focusArea = fragmentView.findViewById(R.id.settings_content_focus_area); 459 if (focusArea == null) { 460 return; 461 } 462 } 463 View finalFocusArea = focusArea; // required to be effectively final for inner class access 464 mGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { 465 @Override 466 public void onGlobalLayout() { 467 boolean success = finalFocusArea.performAccessibilityAction( 468 ACTION_FOCUS, /* arguments= */ null); 469 if (success) { 470 removeGlobalLayoutListener(); 471 } else { 472 findViewById( 473 R.id.settings_focus_parking_view).performAccessibilityAction( 474 ACTION_FOCUS, /* arguments= */ null); 475 } 476 } 477 }; 478 fragmentView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener); 479 } 480 removeGlobalLayoutListener()481 private void removeGlobalLayoutListener() { 482 if (mGlobalLayoutListener == null) { 483 return; 484 } 485 if (getCurrentFragment() == null) { 486 return; 487 } 488 View fragmentView = getCurrentFragment().getView(); 489 if (fragmentView == null) { 490 return; 491 } 492 fragmentView.getViewTreeObserver() 493 .removeOnGlobalLayoutListener(mGlobalLayoutListener); 494 mGlobalLayoutListener = null; 495 } 496 onSearchButtonClicked()497 private void onSearchButtonClicked() { 498 Intent intent = new Intent(Settings.ACTION_APP_SEARCH_SETTINGS) 499 .setPackage(getSettingsIntelligencePkgName()); 500 if (intent.resolveActivity(getPackageManager()) == null) { 501 return; 502 } 503 startActivityForResult(intent, SEARCH_REQUEST_CODE); 504 } 505 getSettingsIntelligencePkgName()506 private String getSettingsIntelligencePkgName() { 507 return getString(R.string.config_settingsintelligence_package_name); 508 } 509 } 510