/* * 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.tv.twopanelsettings; import static com.android.tv.twopanelsettings.slices.SlicesConstants.EXTRA_PREFERENCE_INFO_SUMMARY; import static com.android.tv.twopanelsettings.slices.SlicesConstants.EXTRA_PREFERENCE_INFO_TEXT; import static com.android.tv.twopanelsettings.slices.SlicesConstants.EXTRA_PREFERENCE_INFO_TITLE_ICON; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ArgbEvaluator; import android.animation.ObjectAnimator; import android.content.BroadcastReceiver; import android.content.ContentProviderClient; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.graphics.drawable.Icon; import android.media.AudioManager; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.provider.Settings; import android.text.TextUtils; import android.transition.Fade; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver.OnGlobalLayoutListener; import android.widget.HorizontalScrollView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import androidx.leanback.app.GuidedStepSupportFragment; import androidx.leanback.preference.LeanbackListPreferenceDialogFragmentCompat; import androidx.leanback.preference.LeanbackPreferenceFragmentCompat; import androidx.leanback.widget.OnChildViewHolderSelectedListener; import androidx.leanback.widget.VerticalGridView; import androidx.preference.ListPreference; import androidx.preference.MultiSelectListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceGroupAdapter; import androidx.preference.PreferenceViewHolder; import androidx.recyclerview.widget.RecyclerView; import com.android.tv.twopanelsettings.slices.CustomContentDescriptionPreference; import com.android.tv.twopanelsettings.slices.HasCustomContentDescription; import com.android.tv.twopanelsettings.slices.HasSliceUri; import com.android.tv.twopanelsettings.slices.InfoFragment; import com.android.tv.twopanelsettings.slices.SliceFragment; import com.android.tv.twopanelsettings.slices.SlicePreference; import com.android.tv.twopanelsettings.slices.SliceSwitchPreference; import com.android.tv.twopanelsettings.slices.SlicesConstants; import com.android.tv.twopanelsettings.slices.SliceSeekbarPreference; import java.util.Set; /** * This fragment provides containers for displaying two {@link LeanbackPreferenceFragmentCompat}. * The preference fragment on the left works as a main panel on which the user can operate. * The preference fragment on the right works as a preview panel for displaying the preview * information. */ public abstract class TwoPanelSettingsFragment extends Fragment implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, PreferenceFragmentCompat.OnPreferenceStartScreenCallback, PreferenceFragmentCompat.OnPreferenceDisplayDialogCallback { private static final String TAG = "TwoPanelSettingsFragment"; private static final boolean DEBUG = false; private static final String PREVIEW_FRAGMENT_TAG = "com.android.tv.settings.TwoPanelSettingsFragment.PREVIEW_FRAGMENT"; private static final String PREFERENCE_FRAGMENT_TAG = "com.android.tv.settings.TwoPanelSettingsFragment.PREFERENCE_FRAGMENT"; private static final String EXTRA_PREF_PANEL_IDX = "com.android.tv.twopanelsettings.PREF_PANEL_IDX"; private static final int[] frameResIds = {R.id.frame1, R.id.frame2, R.id.frame3, R.id.frame4, R.id.frame5, R.id.frame6, R.id.frame7, R.id.frame8, R.id.frame9, R.id.frame10}; private static final long PANEL_ANIMATION_MS = 400; private static final long PANEL_ANIMATION_DELAY_MS = 200; private static final long PREVIEW_PANEL_DEFAULT_DELAY_MS = 0; private static final long CHECK_IDLE_STATE_MS = 100; private long mPreviewPanelCreationDelay = 0; private static final float PREVIEW_PANEL_ALPHA = 0.6f; private int mMaxScrollX; private final RootViewOnKeyListener mRootViewOnKeyListener = new RootViewOnKeyListener(); private int mPrefPanelIdx; private HorizontalScrollView mScrollView; private Handler mHandler; private boolean mIsNavigatingBack; private boolean mCheckVerticalGridViewScrollState; private Preference mFocusedPreference; private boolean mIsWaitingForUpdatingPreview = false; private AudioManager mAudioManager; private static final String DELAY_MS = "delay_ms"; private static final String CHECK_SCROLL_STATE = "check_scroll_state"; /** An broadcast receiver to help OEM test best delay for preview panel fragment creation. */ private BroadcastReceiver mPreviewPanelDelayReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { long delay = intent.getLongExtra(DELAY_MS, PREVIEW_PANEL_DEFAULT_DELAY_MS); boolean checkScrollState = intent.getBooleanExtra(CHECK_SCROLL_STATE, false); Log.d(TAG, "New delay for creating preview panel fragment " + delay + " check scroll state " + checkScrollState); mPreviewPanelCreationDelay = delay; mCheckVerticalGridViewScrollState = checkScrollState; } }; private final OnChildViewHolderSelectedListener mOnChildViewHolderSelectedListener = new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(RecyclerView parent, RecyclerView.ViewHolder child, int position, int subposition) { if (parent == null || child == null) { return; } int adapterPosition = child.getAdapterPosition(); PreferenceGroupAdapter preferenceGroupAdapter = (PreferenceGroupAdapter) parent.getAdapter(); if (preferenceGroupAdapter != null) { Preference preference = preferenceGroupAdapter.getItem(adapterPosition); onPreferenceFocused(preference); } } @Override public void onChildViewHolderSelectedAndPositioned(RecyclerView parent, RecyclerView.ViewHolder child, int position, int subposition) { } }; private final OnGlobalLayoutListener mOnGlobalLayoutListener = new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { if (getView() != null && getView().getViewTreeObserver() != null) { getView().getViewTreeObserver().removeOnGlobalLayoutListener( mOnGlobalLayoutListener); moveToPanel(mPrefPanelIdx, false); } } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mCheckVerticalGridViewScrollState = getContext().getResources() .getBoolean(R.bool.config_check_scroll_state); mPreviewPanelCreationDelay = getContext().getResources() .getInteger(R.integer.config_preview_panel_create_delay); mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View v = inflater.inflate(R.layout.two_panel_settings_fragment, container, false); mScrollView = v.findViewById(R.id.scrollview); mHandler = new Handler(); if (savedInstanceState != null) { mPrefPanelIdx = savedInstanceState.getInt(EXTRA_PREF_PANEL_IDX, mPrefPanelIdx); // Move to correct panel once global layout finishes. v.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener); } mMaxScrollX = computeMaxRightScroll(); return v; } @Override public void onSaveInstanceState(Bundle outState) { outState.putInt(EXTRA_PREF_PANEL_IDX, mPrefPanelIdx); super.onSaveInstanceState(outState); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); if (savedInstanceState == null) { onPreferenceStartInitialScreen(); } } /** Extend this method to provide the initial screen **/ public abstract void onPreferenceStartInitialScreen(); private boolean isPreferenceFragment(String fragment) { try { return LeanbackPreferenceFragmentCompat.class.isAssignableFrom(Class.forName(fragment)); } catch (ClassNotFoundException e) { Log.e(TAG, "Fragment class not found " + e); return false; } } private boolean isInfoFragment(String fragment) { try { return InfoFragment.class.isAssignableFrom(Class.forName(fragment)); } catch (ClassNotFoundException e) { Log.e(TAG, "Fragment class not found " + e); return false; } } @Override public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) { if (pref == null) { return false; } if (DEBUG) { Log.d(TAG, "onPreferenceStartFragment " + pref.getTitle()); } if (pref.getFragment() == null) { return false; } Fragment preview = getChildFragmentManager().findFragmentById( frameResIds[mPrefPanelIdx + 1]); if (preview != null && !(preview instanceof DummyFragment)) { if (!(preview instanceof InfoFragment)) { if (!mIsWaitingForUpdatingPreview) { navigateToPreviewFragment(); } } } else { // If there is no corresponding slice provider, thus the corresponding fragment is not // created, return false to check the intent of the SlicePreference. if (pref instanceof SlicePreference) { return false; } try { Fragment immersiveFragment = Fragment.instantiate(getActivity(), pref.getFragment(), pref.getExtras()); startImmersiveFragment(immersiveFragment); } catch (Exception e) { Log.e(TAG, "error trying to instantiate fragment " + e); // return true so it won't be handled by onPreferenceTreeClick in PreferenceFragment return true; } } return true; } /** Navigate back to the previous fragment **/ public void navigateBack() { back(false); } /** Navigate into current preview fragment */ public void navigateToPreviewFragment() { Fragment previewFragment = getChildFragmentManager().findFragmentById( frameResIds[mPrefPanelIdx + 1]); if (previewFragment instanceof NavigationCallback) { ((NavigationCallback) previewFragment).onNavigateToPreview(); } if (previewFragment == null || previewFragment instanceof DummyFragment) { return; } if (DEBUG) { Log.d(TAG, "navigateToPreviewFragment"); } if (mPrefPanelIdx + 1 >= frameResIds.length) { Log.w(TAG, "Maximum level of depth reached."); return; } Fragment initialPreviewFragment = getInitialPreviewFragment(previewFragment); if (initialPreviewFragment == null) { initialPreviewFragment = new DummyFragment(); } initialPreviewFragment.setExitTransition(null); if (previewFragment.getView() != null) { previewFragment.getView().setImportantForAccessibility( View.IMPORTANT_FOR_ACCESSIBILITY_YES); } mPrefPanelIdx++; Fragment fragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); addOrRemovePreferenceFocusedListener(fragment, true); final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); transaction.replace(frameResIds[mPrefPanelIdx + 1], initialPreviewFragment, PREVIEW_FRAGMENT_TAG); transaction.commit(); moveToPanel(mPrefPanelIdx, true); removeFragmentAndAddToBackStack(mPrefPanelIdx - 1); } private boolean isA11yOn() { return Settings.Secure.getInt( getActivity().getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 0) == 1; } private void updateAccessibilityTitle(Fragment fragment) { CharSequence newA11yTitle = ""; if (fragment instanceof SliceFragment) { newA11yTitle = ((SliceFragment) fragment).getScreenTitle(); } else if (fragment instanceof LeanbackPreferenceFragmentCompat) { newA11yTitle = ((LeanbackPreferenceFragmentCompat) fragment).getPreferenceScreen() .getTitle(); } else if (fragment instanceof GuidedStepSupportFragment) { if (fragment.getView() != null) { View titleView = fragment.getView().findViewById(R.id.guidance_title); if (titleView instanceof TextView) { newA11yTitle = ((TextView) titleView).getText(); } } } if (!TextUtils.isEmpty(newA11yTitle)) { if (DEBUG) { Log.d(TAG, "changing a11y title to: " + newA11yTitle); } // Set both window title and pane title to avoid messy announcements when coming from // other activities. (window title is announced on activity change) getActivity().getWindow().setTitle(newA11yTitle); if (getView() != null && getView().findViewById(R.id.two_panel_fragment_container) != null) { getView().findViewById(R.id.two_panel_fragment_container) .setAccessibilityPaneTitle(newA11yTitle); } } } private void addOrRemovePreferenceFocusedListener(Fragment fragment, boolean isAddingListener) { if (!(fragment instanceof LeanbackPreferenceFragmentCompat)) { return; } LeanbackPreferenceFragmentCompat leanbackPreferenceFragment = (LeanbackPreferenceFragmentCompat) fragment; VerticalGridView listView = (VerticalGridView) leanbackPreferenceFragment.getListView(); if (listView != null) { if (isAddingListener) { listView.setOnChildViewHolderSelectedListener(mOnChildViewHolderSelectedListener); } else { listView.setOnChildViewHolderSelectedListener(null); } } } /** * Displays left panel preference fragment to the user. * * @param fragment Fragment instance to be added. */ public void startPreferenceFragment(@NonNull Fragment fragment) { if (DEBUG) { Log.d(TAG, "startPreferenceFragment"); } FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); transaction.add(frameResIds[mPrefPanelIdx], fragment, PREFERENCE_FRAGMENT_TAG); transaction.commitNow(); Fragment initialPreviewFragment = getInitialPreviewFragment(fragment); if (initialPreviewFragment == null) { initialPreviewFragment = new DummyFragment(); } initialPreviewFragment.setExitTransition(null); transaction = getChildFragmentManager().beginTransaction(); transaction.add(frameResIds[mPrefPanelIdx + 1], initialPreviewFragment, initialPreviewFragment.getClass().toString()); transaction.commit(); } @Override public boolean onPreferenceDisplayDialog( @NonNull PreferenceFragmentCompat caller, Preference pref) { if (pref == null) { return false; } if (DEBUG) { Log.d(TAG, "PreferenceDisplayDialog"); } if (caller == null) { throw new IllegalArgumentException("Cannot display dialog for preference " + pref + ", Caller must not be null!"); } Fragment preview = getChildFragmentManager().findFragmentById( frameResIds[mPrefPanelIdx + 1]); if (preview != null && !(preview instanceof DummyFragment)) { if (preview instanceof NavigationCallback) { ((NavigationCallback) preview).onNavigateToPreview(); } mPrefPanelIdx++; moveToPanel(mPrefPanelIdx, true); removeFragmentAndAddToBackStack(mPrefPanelIdx - 1); return true; } return false; } private boolean equalArguments(Bundle a, Bundle b) { if (a == null && b == null) { return true; } if (a == null || b == null) { return false; } Set aks = a.keySet(); Set bks = b.keySet(); if (a.size() != b.size()) { return false; } if (!aks.containsAll(bks)) { return false; } for (String key : aks) { if (a.get(key) == null && b.get(key) == null) { continue; } if (a.get(key) == null || b.get(key) == null) { return false; } if (!a.get(key).equals(b.get(key))) { return false; } } return true; } /** Callback from SliceFragment **/ public interface SliceFragmentCallback { /** Triggered when preference is focused **/ void onPreferenceFocused(Preference preference); /** Triggered when Seekbar preference is changed **/ void onSeekbarPreferenceChanged(SliceSeekbarPreference preference, int addValue); } protected void onPreferenceFocused(Preference pref) { onPreferenceFocusedImpl(pref, false); } private void onPreferenceFocusedImpl(Preference pref, boolean forceRefresh) { if (pref == null) { return; } if (DEBUG) { Log.d(TAG, "onPreferenceFocused " + pref.getTitle()); } final Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); if (prefFragment instanceof SliceFragmentCallback) { ((SliceFragmentCallback) prefFragment).onPreferenceFocused(pref); } mFocusedPreference = pref; if (mCheckVerticalGridViewScrollState || mPreviewPanelCreationDelay > 0) { mIsWaitingForUpdatingPreview = true; VerticalGridView listView = (VerticalGridView) ((LeanbackPreferenceFragmentCompat) prefFragment).getListView(); mHandler.postDelayed(new PostShowPreviewRunnable( listView, pref, forceRefresh), mPreviewPanelCreationDelay); } else { handleFragmentTransactionWhenFocused(pref, forceRefresh); } } private final class PostShowPreviewRunnable implements Runnable { private final VerticalGridView mListView; private final Preference mPref; private final boolean mForceFresh; PostShowPreviewRunnable(VerticalGridView listView, Preference pref, boolean forceFresh) { this.mListView = listView; this.mPref = pref; this.mForceFresh = forceFresh; } @Override public void run() { if (mPref == mFocusedPreference) { if (mListView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { mHandler.postDelayed(this, CHECK_IDLE_STATE_MS); } else { handleFragmentTransactionWhenFocused(mPref, mForceFresh); mIsWaitingForUpdatingPreview = false; } } } } private void handleFragmentTransactionWhenFocused(Preference pref, boolean forceRefresh) { Fragment previewFragment = null; final Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); try { previewFragment = onCreatePreviewFragment(prefFragment, pref); } catch (Exception e) { Log.w(TAG, "Cannot instantiate the fragment from preference: " + pref, e); } if (previewFragment == null) { previewFragment = new DummyFragment(); } final Fragment existingPreviewFragment = getChildFragmentManager().findFragmentById( frameResIds[mPrefPanelIdx + 1]); if (existingPreviewFragment != null && existingPreviewFragment.getClass().equals(previewFragment.getClass()) && equalArguments(existingPreviewFragment.getArguments(), previewFragment.getArguments())) { if (isRTL() && mScrollView.getScrollX() == 0 && mPrefPanelIdx == 0 && getView() != null && getView().getViewTreeObserver() != null) { // For RTL we need to reclaim focus to the correct scroll position if a pref // launches a new activity because the horizontal scroll goes back to 0. getView().getViewTreeObserver().addOnGlobalLayoutListener( mOnGlobalLayoutListener); } if (!forceRefresh) { return; } } // If the existing preview fragment is recreated when the activity is recreated, the // animation would fall back to "slide left", in this case, we need to set the exit // transition. if (existingPreviewFragment != null) { existingPreviewFragment.setExitTransition(null); } previewFragment.setEnterTransition(new Fade()); previewFragment.setExitTransition(null); final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); transaction.setCustomAnimations(android.R.animator.fade_in, android.R.animator.fade_out); transaction.replace(frameResIds[mPrefPanelIdx + 1], previewFragment); transaction.commit(); // Some fragments may steal focus on creation. Reclaim focus on main fragment. if (getView() != null && getView().getViewTreeObserver() != null) { getView().getViewTreeObserver().addOnGlobalLayoutListener( mOnGlobalLayoutListener); } } private boolean onSeekbarPreferenceChanged(SliceSeekbarPreference pref, int addValue) { final Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); if (prefFragment instanceof SliceFragmentCallback) { ((SliceFragmentCallback) prefFragment).onSeekbarPreferenceChanged(pref, addValue); } return true; } private boolean isRTL() { return getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; } @Override public void onResume() { if (DEBUG) { Log.d(TAG, "onResume"); } super.onResume(); IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction("com.android.tv.settings.PREVIEW_DELAY"); getContext().registerReceiver(mPreviewPanelDelayReceiver, intentFilter); // Trap back button presses final TwoPanelSettingsRootView rootView = (TwoPanelSettingsRootView) getView(); if (rootView != null) { rootView.setOnBackKeyListener(mRootViewOnKeyListener); } } @Override public void onPause() { if (DEBUG) { Log.d(TAG, "onPause"); } super.onPause(); getContext().unregisterReceiver(mPreviewPanelDelayReceiver); final TwoPanelSettingsRootView rootView = (TwoPanelSettingsRootView) getView(); if (rootView != null) { rootView.setOnBackKeyListener(null); } } /** * Displays a fragment to the user, temporarily replacing the contents of this fragment. * * @param fragment Fragment instance to be added. */ public void startImmersiveFragment(@NonNull Fragment fragment) { if (DEBUG) { Log.d(TAG, "Starting immersive fragment."); } final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); Fragment target = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); fragment.setTargetFragment(target, 0); transaction .add(R.id.two_panel_fragment_container, fragment) .remove(target) .addToBackStack(null) .commit(); mHandler.post(() -> { updateAccessibilityTitle(fragment); }); } public static class DummyFragment extends Fragment { @Override public @Nullable View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.dummy_fragment, container, false); } } /** * Implement this if fragment needs to handle DPAD_LEFT & DPAD_RIGHT itself in some cases **/ public interface NavigationCallback { /** * Returns true if the fragment is in the state that can navigate back on receiving a * navigation DPAD key. When true, TwoPanelSettings will initiate a back operation on * receiving a left key. This method doesn't apply to back key: back key always initiates a * back operation. */ boolean canNavigateBackOnDPAD(); /** * Callback when navigating to preview screen */ void onNavigateToPreview(); /** * Callback when returning to previous screen */ void onNavigateBack(); } /** * Implement this if the component (typically a Fragment) is preview-able and would like to get * some lifecycle-like callback(s) when the component becomes the main panel. */ public interface PreviewableComponentCallback { /** * Lifecycle-like callback when the component becomes main panel from the preview panel. For * Fragment, this will be invoked right after the preview fragment sliding into the main * panel. * * @param forward means whether the component arrives at main panel when users are * navigating forwards (deeper into the TvSettings tree). */ void onArriveAtMainPanel(boolean forward); } private class RootViewOnKeyListener implements View.OnKeyListener { @Override public boolean onKey(View v, int keyCode, KeyEvent event) { Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); if (event.getAction() == KeyEvent.ACTION_DOWN && (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_LEFT)) { Preference preference = getChosenPreference(prefFragment); if ((preference instanceof SliceSeekbarPreference)) { SliceSeekbarPreference sbPref = (SliceSeekbarPreference) preference; if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { onSeekbarPreferenceChanged(sbPref, 1); } else { onSeekbarPreferenceChanged(sbPref, -1); } return true; } } if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BACK) { return back(true); } if (event.getAction() == KeyEvent.ACTION_DOWN && ((!isRTL() && keyCode == KeyEvent.KEYCODE_DPAD_LEFT) || (isRTL() && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT))) { if (prefFragment instanceof NavigationCallback && !((NavigationCallback) prefFragment).canNavigateBackOnDPAD()) { return false; } return back(false); } if (event.getAction() == KeyEvent.ACTION_DOWN && ((!isRTL() && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) || (isRTL() && keyCode == KeyEvent.KEYCODE_DPAD_LEFT))) { if (shouldPerformClick()) { v.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER)); v.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER)); } else { Fragment previewFragment = getChildFragmentManager() .findFragmentById(frameResIds[mPrefPanelIdx + 1]); if (!(previewFragment instanceof InfoFragment) && !mIsWaitingForUpdatingPreview) { mAudioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_RIGHT); navigateToPreviewFragment(); } } // TODO(b/163432209): improve NavigationCallback and be more specific here. // Do not consume the KeyEvent for NavigationCallback classes such as date & time // picker. return !(prefFragment instanceof NavigationCallback); } return false; } } private boolean shouldPerformClick() { Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); Preference preference = getChosenPreference(prefFragment); if (preference == null) { return false; } // This is for the case when a preference has preview but once user navigate to // see the preview, settings actually launch an intent to start external activity. if (preference.getIntent() != null && !TextUtils.isEmpty(preference.getFragment())) { return true; } return preference instanceof SlicePreference && ((SlicePreference) preference).getSliceAction() != null && ((SlicePreference) preference).getUri() != null; } private boolean back(boolean isKeyBackPressed) { if (mIsNavigatingBack) { mHandler.postDelayed(new Runnable() { @Override public void run() { if (DEBUG) { Log.d(TAG, "Navigating back is deferred."); } back(isKeyBackPressed); } }, PANEL_ANIMATION_DELAY_MS + PANEL_ANIMATION_DELAY_MS); return true; } if (DEBUG) { Log.d(TAG, "Going back one level."); } final Fragment immersiveFragment = getChildFragmentManager().findFragmentById(R.id.two_panel_fragment_container); if (immersiveFragment != null) { getChildFragmentManager().popBackStack(); moveToPanel(mPrefPanelIdx, false); return true; } // When a11y is on, we allow InfoFragments to take focus without scrolling panels. So if // the user presses back button in this state, we should not scroll our panels back, or exit // Settings activity, but rather reinstate the focus to be on the main panel. Fragment preview = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx + 1]); if (isA11yOn() && preview instanceof InfoFragment && preview.getView() != null && preview.getView().hasFocus()) { View mainPanelView = getChildFragmentManager() .findFragmentById(frameResIds[mPrefPanelIdx]).getView(); if (mainPanelView != null) { mainPanelView.requestFocus(); return true; } } if (mPrefPanelIdx < 1) { // Disallow the user to use "dpad left" to finish activity in the first screen if (isKeyBackPressed) { getActivity().finish(); } return true; } mIsNavigatingBack = true; Fragment preferenceFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); addOrRemovePreferenceFocusedListener(preferenceFragment, false); getChildFragmentManager().popBackStack(); mPrefPanelIdx--; mHandler.postDelayed(() -> { if (isKeyBackPressed) { mAudioManager.playSoundEffect(AudioManager.FX_BACK); } else { mAudioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_LEFT); } moveToPanel(mPrefPanelIdx, true); }, PANEL_ANIMATION_DELAY_MS); mHandler.postDelayed(() -> { removeFragment(mPrefPanelIdx + 2); mIsNavigatingBack = false; Fragment previewFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx + 1]); if (previewFragment instanceof NavigationCallback) { ((NavigationCallback) previewFragment).onNavigateBack(); } }, PANEL_ANIMATION_DELAY_MS + PANEL_ANIMATION_DELAY_MS); return true; } private void removeFragment(int index) { Fragment fragment = getChildFragmentManager().findFragmentById(frameResIds[index]); if (fragment != null) { getChildFragmentManager().beginTransaction().remove(fragment).commit(); } } private void removeFragmentAndAddToBackStack(int index) { if (index < 0) { return; } Fragment removePanel = getChildFragmentManager().findFragmentById(frameResIds[index]); if (removePanel != null) { removePanel.setExitTransition(new Fade()); getChildFragmentManager().beginTransaction().remove(removePanel) .addToBackStack("remove " + removePanel.getClass().getName()).commit(); } } /** For RTL layout, we need to know the right edge from where the panels start scrolling. */ private int computeMaxRightScroll() { int scrollViewWidth = getResources().getDimensionPixelSize(R.dimen.tp_settings_panes_width); int panelWidth = getResources().getDimensionPixelSize( R.dimen.tp_settings_preference_pane_width); int panelPadding = getResources().getDimensionPixelSize( R.dimen.preference_pane_extra_padding_start) * 2; int result = frameResIds.length * panelWidth - scrollViewWidth + panelPadding; return result < 0 ? 0 : result; } /** Scrolls such that the panel with given index is the main panel shown on the left. */ private void moveToPanel(final int index, boolean smoothScroll) { mHandler.post(() -> { if (DEBUG) { Log.d(TAG, "Moving to panel " + index); } if (!isAdded()) { return; } Fragment fragmentToBecomeMainPanel = getChildFragmentManager().findFragmentById(frameResIds[index]); Fragment fragmentToBecomePreviewPanel = getChildFragmentManager().findFragmentById(frameResIds[index + 1]); // Positive value means that the panel is scrolling to right (navigate forward for LTR // or navigate backwards for RTL) and vice versa; 0 means that this is likely invoked // by GlobalLayoutListener and there's no actual sliding. int distanceToScrollToRight; int panelWidth = getResources().getDimensionPixelSize( R.dimen.tp_settings_preference_pane_width); View scrollToPanel = getView().findViewById(frameResIds[index]); View previewPanel = getView().findViewById(frameResIds[index + 1]); if (scrollToPanel == null || previewPanel == null) { return; } View scrollToPanelHead = scrollToPanel.findViewById(R.id.decor_title_container); View previewPanelHead = previewPanel.findViewById(R.id.decor_title_container); boolean scrollsToPreview = isRTL() ? mScrollView.getScrollX() >= mMaxScrollX - panelWidth * index : mScrollView.getScrollX() <= panelWidth * index; boolean setAlphaForPreview = fragmentToBecomePreviewPanel != null && !(fragmentToBecomePreviewPanel instanceof DummyFragment) && !(fragmentToBecomePreviewPanel instanceof InfoFragment); int previewPanelColor = getResources().getColor( R.color.tp_preview_panel_background_color); int mainPanelColor = getResources().getColor( R.color.tp_preference_panel_background_color); if (smoothScroll) { int animationEnd = isRTL() ? mMaxScrollX - panelWidth * index : panelWidth * index; distanceToScrollToRight = animationEnd - mScrollView.getScrollX(); // Slide animation ObjectAnimator slideAnim = ObjectAnimator.ofInt(mScrollView, "scrollX", mScrollView.getScrollX(), animationEnd); slideAnim.setAutoCancel(true); slideAnim.setDuration(PANEL_ANIMATION_MS); slideAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); if (isA11yOn() && fragmentToBecomeMainPanel != null && fragmentToBecomeMainPanel.getView() != null) { fragmentToBecomeMainPanel.getView().requestFocus(); } } }); slideAnim.start(); // Color animation if (scrollsToPreview) { previewPanel.setAlpha(setAlphaForPreview ? PREVIEW_PANEL_ALPHA : 1f); previewPanel.setBackgroundColor(previewPanelColor); if (previewPanelHead != null) { previewPanelHead.setBackgroundColor(previewPanelColor); } ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(scrollToPanel, "alpha", scrollToPanel.getAlpha(), 1f); ObjectAnimator backgroundColorAnim = ObjectAnimator.ofObject(scrollToPanel, "backgroundColor", new ArgbEvaluator(), previewPanelColor, mainPanelColor); alphaAnim.setAutoCancel(true); backgroundColorAnim.setAutoCancel(true); AnimatorSet animatorSet = new AnimatorSet(); if (scrollToPanelHead != null) { ObjectAnimator backgroundColorAnimForHead = ObjectAnimator.ofObject( scrollToPanelHead, "backgroundColor", new ArgbEvaluator(), previewPanelColor, mainPanelColor); backgroundColorAnimForHead.setAutoCancel(true); animatorSet.playTogether(alphaAnim, backgroundColorAnim, backgroundColorAnimForHead); } else { animatorSet.playTogether(alphaAnim, backgroundColorAnim); } animatorSet.setDuration(PANEL_ANIMATION_MS); animatorSet.start(); } else { scrollToPanel.setAlpha(1f); scrollToPanel.setBackgroundColor(mainPanelColor); if (scrollToPanelHead != null) { scrollToPanelHead.setBackgroundColor(mainPanelColor); } ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(previewPanel, "alpha", previewPanel.getAlpha(), setAlphaForPreview ? PREVIEW_PANEL_ALPHA : 1f); ObjectAnimator backgroundColorAnim = ObjectAnimator.ofObject(previewPanel, "backgroundColor", new ArgbEvaluator(), mainPanelColor, previewPanelColor); alphaAnim.setAutoCancel(true); backgroundColorAnim.setAutoCancel(true); AnimatorSet animatorSet = new AnimatorSet(); if (previewPanelHead != null) { ObjectAnimator backgroundColorAnimForHead = ObjectAnimator.ofObject( previewPanelHead, "backgroundColor", new ArgbEvaluator(), mainPanelColor, previewPanelColor); backgroundColorAnimForHead.setAutoCancel(true); animatorSet.playTogether(alphaAnim, backgroundColorAnim, backgroundColorAnimForHead); } else { animatorSet.playTogether(alphaAnim, backgroundColorAnim); } animatorSet.setDuration(PANEL_ANIMATION_MS); animatorSet.start(); } } else { int scrollToX = isRTL() ? mMaxScrollX - panelWidth * index : panelWidth * index; distanceToScrollToRight = scrollToX - mScrollView.getScrollX(); mScrollView.scrollTo(scrollToX, 0); previewPanel.setAlpha(setAlphaForPreview ? PREVIEW_PANEL_ALPHA : 1f); previewPanel.setBackgroundColor(previewPanelColor); if (previewPanelHead != null) { previewPanelHead.setBackgroundColor(previewPanelColor); } scrollToPanel.setAlpha(1f); scrollToPanel.setBackgroundColor(mainPanelColor); if (scrollToPanelHead != null) { scrollToPanelHead.setBackgroundColor(mainPanelColor); } } if (fragmentToBecomeMainPanel != null && fragmentToBecomeMainPanel.getView() != null) { if (!isA11yOn()) { fragmentToBecomeMainPanel.getView().requestFocus(); } for (int resId : frameResIds) { Fragment f = getChildFragmentManager().findFragmentById(resId); if (f != null) { View view = f.getView(); if (view != null) { view.setImportantForAccessibility( f == fragmentToBecomeMainPanel || f instanceof InfoFragment ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); } } } if (fragmentToBecomeMainPanel instanceof PreviewableComponentCallback) { if (distanceToScrollToRight > 0) { ((PreviewableComponentCallback) fragmentToBecomeMainPanel) .onArriveAtMainPanel(!isRTL()); } else if (distanceToScrollToRight < 0) { ((PreviewableComponentCallback) fragmentToBecomeMainPanel) .onArriveAtMainPanel(isRTL()); } // distanceToScrollToRight being 0 means no actual panel sliding; thus noop. } updateAccessibilityTitle(fragmentToBecomeMainPanel); } }); } private Fragment getInitialPreviewFragment(Fragment fragment) { if (!(fragment instanceof LeanbackPreferenceFragmentCompat)) { return null; } LeanbackPreferenceFragmentCompat leanbackPreferenceFragment = (LeanbackPreferenceFragmentCompat) fragment; if (leanbackPreferenceFragment.getListView() == null) { return null; } VerticalGridView listView = (VerticalGridView) leanbackPreferenceFragment.getListView(); int position = listView.getSelectedPosition(); PreferenceGroupAdapter adapter = (PreferenceGroupAdapter) (leanbackPreferenceFragment.getListView().getAdapter()); if (adapter == null) { return null; } Preference chosenPreference = adapter.getItem(position); // Find the first focusable preference if cannot find the selected preference if (chosenPreference == null || (listView.findViewHolderForPosition(position) != null && !listView.findViewHolderForPosition(position).itemView.hasFocusable())) { chosenPreference = null; for (int i = 0; i < listView.getChildCount(); i++) { View view = listView.getChildAt(i); if (view.hasFocusable()) { PreferenceViewHolder viewHolder = (PreferenceViewHolder) listView.getChildViewHolder(view); chosenPreference = adapter.getItem(viewHolder.getAdapterPosition()); break; } } } if (chosenPreference == null) { return null; } return onCreatePreviewFragment(fragment, chosenPreference); } /** * Refocus the current selected preference. When a preference is selected and its InfoFragment * slice data changes. We need to call this method to make sure InfoFragment updates in time. * This is also helpful in refreshing preview of ListPreference. */ public void refocusPreference(Fragment fragment) { if (!isFragmentInTheMainPanel(fragment)) { return; } Preference chosenPreference = getChosenPreference(fragment); try { if (chosenPreference != null) { if (chosenPreference.getFragment() != null && InfoFragment.class.isAssignableFrom( Class.forName(chosenPreference.getFragment()))) { updateInfoFragmentStatus(fragment); } if (chosenPreference instanceof ListPreference) { refocusPreferenceForceRefresh(chosenPreference); } } } catch (ClassNotFoundException e) { e.printStackTrace(); } } /** Force refresh preview panel. */ public void refocusPreferenceForceRefresh(Preference chosenPreference) { onPreferenceFocusedImpl(chosenPreference, true); } /** Show error message in preview panel **/ public void showErrorMessage(String errorMessage, Fragment fragment) { Fragment prefFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); if (fragment == prefFragment) { // If user has already navigated to the preview screen, main panel screen should be // updated to new InFoFragment. Create a fake preference to work around this case. Preference preference = new Preference(getContext()); updatePreferenceWithErrorMessage(preference, errorMessage, getContext()); Fragment newPrefFragment = onCreatePreviewFragment(null, preference); final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); transaction.setCustomAnimations(android.R.animator.fade_in, android.R.animator.fade_out); transaction.replace(frameResIds[mPrefPanelIdx], newPrefFragment); transaction.commit(); } else { Preference preference = getChosenPreference(prefFragment); if (preference != null) { if (isA11yOn()) { appendErrorToContentDescription(prefFragment, errorMessage); } updatePreferenceWithErrorMessage(preference, errorMessage, getContext()); onPreferenceFocused(preference); } } } private static void updatePreferenceWithErrorMessage( Preference preference, String errorMessage, Context context) { preference.setFragment(InfoFragment.class.getCanonicalName()); Bundle b = preference.getExtras(); b.putParcelable(EXTRA_PREFERENCE_INFO_TITLE_ICON, Icon.createWithResource(context, R.drawable.slice_error_icon)); b.putCharSequence(EXTRA_PREFERENCE_INFO_TEXT, context.getString(R.string.status_unavailable)); b.putCharSequence(EXTRA_PREFERENCE_INFO_SUMMARY, errorMessage); } private void appendErrorToContentDescription(Fragment fragment, String errorMessage) { Preference preference = getChosenPreference(fragment); String errorMessageContentDescription = ""; if (preference.getTitle() != null) { errorMessageContentDescription += preference.getTitle().toString(); } errorMessageContentDescription += HasCustomContentDescription.CONTENT_DESCRIPTION_SEPARATOR + getString(R.string.status_unavailable) + HasCustomContentDescription.CONTENT_DESCRIPTION_SEPARATOR + errorMessage; if (preference instanceof SlicePreference) { ((SlicePreference) preference).setContentDescription(errorMessageContentDescription); } else if (preference instanceof SliceSwitchPreference) { ((SliceSwitchPreference) preference) .setContentDescription(errorMessageContentDescription); } else if (preference instanceof CustomContentDescriptionPreference) { ((CustomContentDescriptionPreference) preference) .setContentDescription(errorMessageContentDescription); } LeanbackPreferenceFragmentCompat leanbackPreferenceFragment = (LeanbackPreferenceFragmentCompat) fragment; if (leanbackPreferenceFragment.getListView() != null && leanbackPreferenceFragment.getListView().getAdapter() != null) { leanbackPreferenceFragment.getListView().getAdapter().notifyDataSetChanged(); } } private void updateInfoFragmentStatus(Fragment fragment) { if (!isFragmentInTheMainPanel(fragment)) { return; } final Fragment existingPreviewFragment = getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx + 1]); if (existingPreviewFragment instanceof InfoFragment) { ((InfoFragment) existingPreviewFragment).updateInfoFragment(); } } /** Get the current chosen preference. */ public static Preference getChosenPreference(Fragment fragment) { if (!(fragment instanceof LeanbackPreferenceFragmentCompat)) { return null; } LeanbackPreferenceFragmentCompat leanbackPreferenceFragment = (LeanbackPreferenceFragmentCompat) fragment; if (leanbackPreferenceFragment.getListView() == null) { return null; } VerticalGridView listView = (VerticalGridView) leanbackPreferenceFragment.getListView(); int position = listView.getSelectedPosition(); PreferenceGroupAdapter adapter = (PreferenceGroupAdapter) (leanbackPreferenceFragment.getListView().getAdapter()); return adapter != null ? adapter.getItem(position) : null; } /** Creates preview preference fragment. */ public Fragment onCreatePreviewFragment(Fragment caller, Preference preference) { if (preference == null) { return null; } if (preference.getFragment() != null) { if (!isInfoFragment(preference.getFragment()) && !isPreferenceFragment(preference.getFragment())) { return null; } if (isPreferenceFragment(preference.getFragment()) && preference instanceof HasSliceUri) { HasSliceUri slicePref = (HasSliceUri) preference; if (slicePref.getUri() == null || !isUriValid(slicePref.getUri())) { return null; } Bundle b = preference.getExtras(); b.putString(SlicesConstants.TAG_TARGET_URI, slicePref.getUri()); b.putCharSequence(SlicesConstants.TAG_SCREEN_TITLE, preference.getTitle()); } return Fragment.instantiate(getActivity(), preference.getFragment(), preference.getExtras()); } else { Fragment f = null; if (preference instanceof ListPreference) { f = TwoPanelListPreferenceDialogFragment.newInstanceSingle(preference.getKey()); } else if (preference instanceof MultiSelectListPreference) { f = LeanbackListPreferenceDialogFragmentCompat.newInstanceMulti( preference.getKey()); } if (f != null && caller != null) { f.setTargetFragment(caller, 0); } return f; } } private boolean isUriValid(String uri) { if (uri == null) { return false; } ContentProviderClient client = getContext().getContentResolver().acquireContentProviderClient(Uri.parse(uri)); if (client != null) { client.close(); return true; } else { return false; } } /** Add focus listener to the child fragment **/ public void addListenerForFragment(Fragment fragment) { if (isFragmentInTheMainPanel(fragment)) { addOrRemovePreferenceFocusedListener(fragment, true); } } /** Remove focus listener from the child fragment **/ public void removeListenerForFragment(Fragment fragment) { addOrRemovePreferenceFocusedListener(fragment, false); } /** Check if fragment is in the main panel **/ public boolean isFragmentInTheMainPanel(Fragment fragment) { return fragment == getChildFragmentManager().findFragmentById(frameResIds[mPrefPanelIdx]); } }