blob: 52ee74acf1f74fb83d78a2e75857cb8da29f26d0 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.settings.common;
import static android.view.ViewGroup.FOCUS_BEFORE_DESCENDANTS;
import static android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS;
import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
import android.car.drivingstate.CarUxRestrictions;
import android.car.drivingstate.CarUxRestrictionsManager;
import android.car.drivingstate.CarUxRestrictionsManager.OnUxRestrictionsChangedListener;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.provider.Settings;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.inputmethod.InputMethodManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.android.car.apps.common.util.Themes;
import com.android.car.settings.R;
import com.android.car.settings.common.rotary.SettingsFocusParkingView;
import com.android.car.ui.baselayout.Insets;
import com.android.car.ui.baselayout.InsetsChangedListener;
import com.android.car.ui.core.CarUi;
import com.android.car.ui.toolbar.MenuItem;
import com.android.car.ui.toolbar.NavButtonMode;
import com.android.car.ui.toolbar.ToolbarController;
import com.android.settingslib.core.lifecycle.HideNonSystemOverlayMixin;
import java.util.Collections;
import java.util.List;
/**
* Base activity class for car settings, provides a action bar with a back button that goes to
* previous activity.
*/
public abstract class BaseCarSettingsActivity extends FragmentActivity implements
FragmentHost, OnUxRestrictionsChangedListener, UxRestrictionsProvider,
OnBackStackChangedListener, PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
InsetsChangedListener {
/**
* Meta data key for specifying the preference key of the top level menu preference that the
* initial activity's fragment falls under. If this is not specified in the activity's
* metadata, the top level menu preference will not be highlighted upon activity launch.
*/
public static final String META_DATA_KEY_HEADER_KEY =
"com.android.car.settings.TOP_LEVEL_HEADER_KEY";
/**
* Meta data key for specifying activities that should always be shown in the single pane
* configuration. If not specified for the activity, the activity will default to the value
* {@link R.bool.config_global_force_single_pane}.
*/
public static final String META_DATA_KEY_SINGLE_PANE = "com.android.car.settings.SINGLE_PANE";
private static final Logger LOG = new Logger(BaseCarSettingsActivity.class);
private static final int SEARCH_REQUEST_CODE = 501;
private static final String KEY_HAS_NEW_INTENT = "key_has_new_intent";
private boolean mHasNewIntent = true;
private boolean mHasInitialFocus = false;
private boolean mIsInitialFragmentTransaction = true;
private String mTopLevelHeaderKey;
private boolean mIsSinglePane;
private ToolbarController mGlobalToolbar;
private ToolbarController mMiniToolbar;
private CarUxRestrictionsHelper mUxRestrictionsHelper;
private ViewGroup mFragmentContainer;
private View mRestrictedMessage;
// Default to minimum restriction.
private CarUxRestrictions mCarUxRestrictions = new CarUxRestrictions.Builder(
/* reqOpt= */ true,
CarUxRestrictions.UX_RESTRICTIONS_BASELINE,
/* timestamp= */ 0
).build();
private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener;
private final ViewTreeObserver.OnGlobalFocusChangeListener mFocusChangeListener =
(oldFocus, newFocus) -> {
if (oldFocus instanceof SettingsFocusParkingView) {
// Focus is manually shifted away from the SettingsFocusParkingView.
// Therefore, the focus should no longer shift upon global layout.
removeGlobalLayoutListener();
}
if (newFocus instanceof SettingsFocusParkingView && mGlobalLayoutListener == null) {
// Attempting to shift focus to the SettingsFocusParkingView without a layout
// listener is not allowed, since it can cause undermined focus behavior
// in these rare edge cases.
requestTopLevelMenuFocus();
}
// This will maintain focus in the content pane if a view goes from
// focusable -> unfocusable.
if (oldFocus == null && mHasInitialFocus) {
requestContentPaneFocus();
} else {
mHasInitialFocus = true;
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLifecycle().addObserver(new HideNonSystemOverlayMixin(this));
if (savedInstanceState != null) {
mHasNewIntent = savedInstanceState.getBoolean(KEY_HAS_NEW_INTENT, mHasNewIntent);
}
populateMetaData();
setContentView(R.layout.car_setting_activity);
mFragmentContainer = findViewById(R.id.fragment_container);
// We do this so that the insets are not automatically sent to the fragments.
// The fragments have their own insets handled by the installBaseLayoutAround() method.
CarUi.replaceInsetsChangedListenerWith(this, this);
setUpToolbars();
getSupportFragmentManager().addOnBackStackChangedListener(this);
mRestrictedMessage = findViewById(R.id.restricted_message);
if (mHasNewIntent) {
launchIfDifferent(getInitialFragment());
mHasNewIntent = false;
} else if (!mIsSinglePane) {
updateMiniToolbarState();
}
mUxRestrictionsHelper = new CarUxRestrictionsHelper(/* context= */ this, /* listener= */
this);
if (shouldFocusContentOnLaunch()) {
requestContentPaneFocus();
mHasInitialFocus = true;
} else {
requestTopLevelMenuFocus();
}
setUpFocusChangeListener(true);
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(KEY_HAS_NEW_INTENT, mHasNewIntent);
}
@Override
public void onDestroy() {
setUpFocusChangeListener(false);
removeGlobalLayoutListener();
mUxRestrictionsHelper.destroy();
mUxRestrictionsHelper = null;
super.onDestroy();
}
@Override
public void onBackPressed() {
super.onBackPressed();
hideKeyboard();
// If the backstack is empty, finish the activity.
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
finish();
}
}
@Override
public Intent getIntent() {
Intent superIntent = super.getIntent();
if (mTopLevelHeaderKey != null) {
superIntent.putExtra(META_DATA_KEY_HEADER_KEY, mTopLevelHeaderKey);
}
superIntent.putExtra(META_DATA_KEY_SINGLE_PANE, mIsSinglePane);
return superIntent;
}
@Override
public void launchFragment(Fragment fragment) {
if (fragment instanceof DialogFragment) {
throw new IllegalArgumentException(
"cannot launch dialogs with launchFragment() - use showDialog() instead");
}
if (mIsSinglePane) {
Intent intent = SubSettingsActivity.newInstance(/* context= */ this, fragment);
startActivity(intent);
} else {
launchFragmentInternal(fragment);
}
}
protected void launchFragmentInternal(Fragment fragment) {
getSupportFragmentManager()
.beginTransaction()
.setCustomAnimations(
Themes.getAttrResourceId(/* context= */ this,
android.R.attr.fragmentOpenEnterAnimation),
Themes.getAttrResourceId(/* context= */ this,
android.R.attr.fragmentOpenExitAnimation),
Themes.getAttrResourceId(/* context= */ this,
android.R.attr.fragmentCloseEnterAnimation),
Themes.getAttrResourceId(/* context= */ this,
android.R.attr.fragmentCloseExitAnimation))
.replace(R.id.fragment_container, fragment,
Integer.toString(getSupportFragmentManager().getBackStackEntryCount()))
.addToBackStack(null)
.commit();
}
@Override
public void goBack() {
onBackPressed();
}
@Override
public void showBlockingMessage() {
Toast.makeText(this, R.string.restricted_while_driving, Toast.LENGTH_SHORT).show();
}
@Override
public ToolbarController getToolbar() {
if (mIsSinglePane) {
return mGlobalToolbar;
}
return mMiniToolbar;
}
@Override
public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) {
mCarUxRestrictions = restrictionInfo;
// Update restrictions for current fragment.
Fragment currentFragment = getCurrentFragment();
if (currentFragment instanceof OnUxRestrictionsChangedListener) {
((OnUxRestrictionsChangedListener) currentFragment)
.onUxRestrictionsChanged(restrictionInfo);
}
updateBlockingView(currentFragment);
if (!mIsSinglePane) {
// Update restrictions for top level menu (if present).
Fragment topLevelMenu =
getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
if (topLevelMenu instanceof CarUxRestrictionsManager.OnUxRestrictionsChangedListener) {
((CarUxRestrictionsManager.OnUxRestrictionsChangedListener) topLevelMenu)
.onUxRestrictionsChanged(restrictionInfo);
}
}
}
@Override
public CarUxRestrictions getCarUxRestrictions() {
return mCarUxRestrictions;
}
@Override
public void onBackStackChanged() {
onUxRestrictionsChanged(getCarUxRestrictions());
if (!mIsSinglePane) {
if (mHasInitialFocus && shouldFocusContentOnBackstackChange()) {
requestContentPaneFocus();
}
updateMiniToolbarState();
}
}
@Override
public void onCarUiInsetsChanged(Insets insets) {
// intentional no-op - insets are handled by the listeners created during toolbar setup
}
@Override
public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) {
if (pref.getFragment() != null) {
Fragment fragment = Fragment.instantiate(/* context= */ this, pref.getFragment(),
pref.getExtras());
launchFragment(fragment);
return true;
}
return false;
}
/**
* Gets the fragment to show onCreate. If null, the activity will not perform an initial
* fragment transaction.
*/
@Nullable
protected abstract Fragment getInitialFragment();
protected Fragment getCurrentFragment() {
return getSupportFragmentManager().findFragmentById(R.id.fragment_container);
}
/**
* Returns whether the content pane should get focus initially when in dual-pane configuration.
*/
protected boolean shouldFocusContentOnLaunch() {
return true;
}
private void launchIfDifferent(Fragment newFragment) {
Fragment currentFragment = getCurrentFragment();
if ((newFragment != null) && differentFragment(newFragment, currentFragment)) {
LOG.d("launchIfDifferent: " + newFragment + " replacing " + currentFragment);
launchFragmentInternal(newFragment);
}
}
/**
* Returns {code true} if newFragment is different from current fragment.
*/
private boolean differentFragment(Fragment newFragment, Fragment currentFragment) {
return (currentFragment == null)
|| (!currentFragment.getClass().equals(newFragment.getClass()));
}
private void hideKeyboard() {
InputMethodManager imm = (InputMethodManager) this.getSystemService(
Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0);
}
private void updateBlockingView(@Nullable Fragment currentFragment) {
if (mRestrictedMessage == null) {
return;
}
if (currentFragment instanceof BaseFragment
&& !((BaseFragment) currentFragment).canBeShown(mCarUxRestrictions)) {
mRestrictedMessage.setVisibility(View.VISIBLE);
mFragmentContainer.setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
mFragmentContainer.clearFocus();
hideKeyboard();
} else {
mRestrictedMessage.setVisibility(View.GONE);
mFragmentContainer.setDescendantFocusability(FOCUS_BEFORE_DESCENDANTS);
}
}
private void populateMetaData() {
try {
ActivityInfo ai = getPackageManager().getActivityInfo(getComponentName(),
PackageManager.GET_META_DATA);
if (ai == null || ai.metaData == null) {
mIsSinglePane = getResources().getBoolean(R.bool.config_global_force_single_pane);
return;
}
mTopLevelHeaderKey = ai.metaData.getString(META_DATA_KEY_HEADER_KEY);
mIsSinglePane = ai.metaData.getBoolean(META_DATA_KEY_SINGLE_PANE,
getResources().getBoolean(R.bool.config_global_force_single_pane));
} catch (PackageManager.NameNotFoundException e) {
LOG.w("Unable to find package", e);
}
}
private void setUpToolbars() {
View globalToolbarWrappedView = mIsSinglePane ? findViewById(
R.id.fragment_container_wrapper) : findViewById(R.id.top_level_menu_container);
mGlobalToolbar = CarUi.installBaseLayoutAround(
globalToolbarWrappedView,
insets -> globalToolbarWrappedView.setPadding(
insets.getLeft(), insets.getTop(), insets.getRight(),
insets.getBottom()), /* hasToolbar= */ true);
if (mIsSinglePane) {
mGlobalToolbar.setNavButtonMode(NavButtonMode.BACK);
findViewById(R.id.top_level_menu_container).setVisibility(View.GONE);
findViewById(R.id.top_level_divider).setVisibility(View.GONE);
return;
}
mMiniToolbar = CarUi.installBaseLayoutAround(
findViewById(R.id.fragment_container_wrapper),
insets -> findViewById(R.id.fragment_container_wrapper).setPadding(
insets.getLeft(), insets.getTop(), insets.getRight(),
insets.getBottom()), /* hasToolbar= */ true);
MenuItem searchButton = new MenuItem.Builder(this)
.setToSearch()
.setOnClickListener(i -> onSearchButtonClicked())
.setUxRestrictions(CarUxRestrictions.UX_RESTRICTIONS_NO_KEYBOARD)
.setId(R.id.toolbar_menu_item_0)
.build();
List<MenuItem> items = Collections.singletonList(searchButton);
mGlobalToolbar.setTitle(R.string.settings_label);
mGlobalToolbar.setNavButtonMode(NavButtonMode.DISABLED);
mGlobalToolbar.setLogo(R.drawable.ic_launcher_settings);
mGlobalToolbar.setMenuItems(items);
}
private void updateMiniToolbarState() {
if (mMiniToolbar == null) {
return;
}
if (getSupportFragmentManager().getBackStackEntryCount() > 1 || !isTaskRoot()) {
mMiniToolbar.setNavButtonMode(NavButtonMode.BACK);
} else {
mMiniToolbar.setNavButtonMode(NavButtonMode.DISABLED);
}
}
private void setUpFocusChangeListener(boolean enable) {
if (mIsSinglePane) {
// The focus change listener is only needed with two panes.
return;
}
ViewTreeObserver observer = findViewById(
R.id.car_settings_activity_wrapper).getViewTreeObserver();
if (enable) {
observer.addOnGlobalFocusChangeListener(mFocusChangeListener);
} else {
observer.removeOnGlobalFocusChangeListener(mFocusChangeListener);
}
}
private void requestTopLevelMenuFocus() {
if (mIsSinglePane) {
return;
}
Fragment topLevelMenu = getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
if (topLevelMenu == null) {
return;
}
View fragmentView = topLevelMenu.getView();
if (fragmentView == null) {
return;
}
View focusArea = fragmentView.findViewById(R.id.settings_car_ui_focus_area);
if (focusArea == null) {
return;
}
removeGlobalLayoutListener();
mGlobalLayoutListener = () -> {
if (focusArea.isInTouchMode() || focusArea.hasFocus()) {
return;
}
focusArea.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
removeGlobalLayoutListener();
};
fragmentView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
}
private void requestContentPaneFocus() {
if (mIsSinglePane) {
return;
}
if (getCurrentFragment() == null) {
return;
}
View fragmentView = getCurrentFragment().getView();
if (fragmentView == null) {
return;
}
removeGlobalLayoutListener();
if (fragmentView.isInTouchMode()) {
mHasInitialFocus = false;
return;
}
View focusArea = fragmentView.findViewById(R.id.settings_car_ui_focus_area);
if (focusArea == null) {
focusArea = fragmentView.findViewById(R.id.settings_content_focus_area);
if (focusArea == null) {
return;
}
}
removeGlobalLayoutListener();
View finalFocusArea = focusArea; // required to be effectively final for inner class access
mGlobalLayoutListener = () -> {
if (finalFocusArea.isInTouchMode() || finalFocusArea.hasFocus()) {
return;
}
boolean success = finalFocusArea.performAccessibilityAction(
ACTION_FOCUS, /* arguments= */ null);
if (success) {
removeGlobalLayoutListener();
} else {
findViewById(
R.id.settings_focus_parking_view).performAccessibilityAction(
ACTION_FOCUS, /* arguments= */ null);
}
};
fragmentView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
}
private boolean shouldFocusContentOnBackstackChange() {
// We don't want to reset mHasInitialFocus when initial fragment is added
if (mIsInitialFragmentTransaction && getInitialFragment() != null) {
mIsInitialFragmentTransaction = false;
return false;
}
return true;
}
private void removeGlobalLayoutListener() {
if (mGlobalLayoutListener == null) {
return;
}
// Check content pane
Fragment contentFragment = getCurrentFragment();
if (contentFragment != null && contentFragment.getView() != null) {
contentFragment.getView().getViewTreeObserver()
.removeOnGlobalLayoutListener(mGlobalLayoutListener);
}
// Check top level menu
Fragment topLevelMenu = getSupportFragmentManager().findFragmentById(R.id.top_level_menu);
if (topLevelMenu != null && topLevelMenu.getView() != null) {
topLevelMenu.getView().getViewTreeObserver()
.removeOnGlobalLayoutListener(mGlobalLayoutListener);
}
mGlobalLayoutListener = null;
}
private void onSearchButtonClicked() {
Intent intent = new Intent(Settings.ACTION_APP_SEARCH_SETTINGS)
.setPackage(getSettingsIntelligencePkgName());
if (intent.resolveActivity(getPackageManager()) == null) {
return;
}
startActivityForResult(intent, SEARCH_REQUEST_CODE);
}
private String getSettingsIntelligencePkgName() {
return getString(R.string.config_settingsintelligence_package_name);
}
}