1 /*
2  * Copyright (C) 2018 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.settings.panel;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.AnimatorSet;
22 import android.animation.ObjectAnimator;
23 import android.animation.ValueAnimator;
24 import android.app.settings.SettingsEnums;
25 import android.net.Uri;
26 import android.os.Bundle;
27 import android.os.Handler;
28 import android.text.TextUtils;
29 import android.view.Gravity;
30 import android.view.LayoutInflater;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.view.ViewTreeObserver;
34 import android.view.animation.DecelerateInterpolator;
35 import android.widget.Button;
36 import android.widget.ImageView;
37 import android.widget.LinearLayout;
38 import android.widget.ProgressBar;
39 import android.widget.TextView;
40 
41 import androidx.annotation.NonNull;
42 import androidx.annotation.Nullable;
43 import androidx.core.graphics.drawable.IconCompat;
44 import androidx.fragment.app.Fragment;
45 import androidx.fragment.app.FragmentActivity;
46 import androidx.lifecycle.LifecycleObserver;
47 import androidx.lifecycle.LiveData;
48 import androidx.recyclerview.widget.LinearLayoutManager;
49 import androidx.recyclerview.widget.RecyclerView;
50 import androidx.slice.Slice;
51 import androidx.slice.SliceMetadata;
52 import androidx.slice.widget.SliceLiveData;
53 
54 import com.android.internal.annotations.VisibleForTesting;
55 import com.android.settings.R;
56 import com.android.settings.overlay.FeatureFactory;
57 import com.android.settings.panel.PanelLoggingContract.PanelClosedKeys;
58 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
59 import com.android.settingslib.utils.ThreadUtils;
60 
61 import com.google.android.setupdesign.DividerItemDecoration;
62 
63 import java.util.Arrays;
64 import java.util.LinkedHashMap;
65 import java.util.List;
66 import java.util.Map;
67 
68 public class PanelFragment extends Fragment {
69 
70     private static final String TAG = "PanelFragment";
71 
72     /**
73      * Duration of the animation entering the screen, in milliseconds.
74      */
75     private static final int DURATION_ANIMATE_PANEL_EXPAND_MS = 250;
76 
77     /**
78      * Duration of the animation exiting the screen, in milliseconds.
79      */
80     private static final int DURATION_ANIMATE_PANEL_COLLAPSE_MS = 200;
81 
82     /**
83      * Duration of timeout waiting for Slice data to bind, in milliseconds.
84      */
85     private static final int DURATION_SLICE_BINDING_TIMEOUT_MS = 250;
86 
87     @VisibleForTesting
88     View mLayoutView;
89     private TextView mTitleView;
90     private Button mSeeMoreButton;
91     private Button mDoneButton;
92     private RecyclerView mPanelSlices;
93     private PanelContent mPanel;
94     private MetricsFeatureProvider mMetricsProvider;
95     private String mPanelClosedKey;
96     private LinearLayout mPanelHeader;
97     private ImageView mTitleIcon;
98     private LinearLayout mTitleGroup;
99     private LinearLayout mHeaderLayout;
100     private TextView mHeaderTitle;
101     private TextView mHeaderSubtitle;
102     private int mMaxHeight;
103     private boolean mPanelCreating;
104     private ProgressBar mProgressBar;
105 
106     private final Map<Uri, LiveData<Slice>> mSliceLiveData = new LinkedHashMap<>();
107 
108     @VisibleForTesting
109     PanelSlicesLoaderCountdownLatch mPanelSlicesLoaderCountdownLatch;
110 
111     private ViewTreeObserver.OnPreDrawListener mOnPreDrawListener = () -> {
112         return false;
113     };
114 
115     private final ViewTreeObserver.OnGlobalLayoutListener mPanelLayoutListener =
116             new ViewTreeObserver.OnGlobalLayoutListener() {
117                 @Override
118                 public void onGlobalLayout() {
119                     if (mLayoutView.getHeight() > mMaxHeight) {
120                         final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams();
121                         params.height = mMaxHeight;
122                         mLayoutView.setLayoutParams(params);
123                     }
124                 }
125             };
126 
127     private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener =
128             new ViewTreeObserver.OnGlobalLayoutListener() {
129                 @Override
130                 public void onGlobalLayout() {
131                     animateIn();
132                     if (mPanelSlices != null) {
133                         mPanelSlices.getViewTreeObserver().removeOnGlobalLayoutListener(this);
134                     }
135                     mPanelCreating = false;
136                 }
137             };
138 
139     private PanelSlicesAdapter mAdapter;
140 
141     @Nullable
142     @Override
onCreateView(@onNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)143     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
144             @Nullable Bundle savedInstanceState) {
145         mLayoutView = inflater.inflate(R.layout.panel_layout, container, false);
146         mLayoutView.getViewTreeObserver()
147                 .addOnGlobalLayoutListener(mPanelLayoutListener);
148         mMaxHeight = getResources().getDimensionPixelSize(R.dimen.output_switcher_slice_max_height);
149         mPanelCreating = true;
150         createPanelContent();
151         return mLayoutView;
152     }
153 
154     /**
155      * Animate the old panel out from the screen, then update the panel with new content once the
156      * animation is done.
157      * <p>
158      * Takes the entire panel and animates out from behind the navigation bar.
159      * <p>
160      * Call createPanelContent() once animation end.
161      */
updatePanelWithAnimation()162     void updatePanelWithAnimation() {
163         mPanelCreating = true;
164         final View panelContent = mLayoutView.findViewById(R.id.panel_container);
165         final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView,
166                 0.0f /* startY */, panelContent.getHeight() /* endY */,
167                 1.0f /* startAlpha */, 0.0f /* endAlpha */,
168                 DURATION_ANIMATE_PANEL_COLLAPSE_MS);
169 
170         final ValueAnimator animator = new ValueAnimator();
171         animator.setFloatValues(0.0f, 1.0f);
172         animatorSet.play(animator);
173         animatorSet.addListener(new AnimatorListenerAdapter() {
174             @Override
175             public void onAnimationEnd(Animator animation) {
176                 createPanelContent();
177             }
178         });
179         animatorSet.start();
180     }
181 
isPanelCreating()182     boolean isPanelCreating() {
183         return mPanelCreating;
184     }
185 
createPanelContent()186     private void createPanelContent() {
187         final FragmentActivity activity = getActivity();
188         if (activity == null) {
189             return;
190         }
191 
192         if (mLayoutView == null) {
193             activity.finish();
194             return;
195         }
196 
197         final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams();
198         params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
199         mLayoutView.setLayoutParams(params);
200 
201         mPanelSlices = mLayoutView.findViewById(R.id.panel_parent_layout);
202         mSeeMoreButton = mLayoutView.findViewById(R.id.see_more);
203         mDoneButton = mLayoutView.findViewById(R.id.done);
204         mTitleView = mLayoutView.findViewById(R.id.panel_title);
205         mPanelHeader = mLayoutView.findViewById(R.id.panel_header);
206         mTitleIcon = mLayoutView.findViewById(R.id.title_icon);
207         mTitleGroup = mLayoutView.findViewById(R.id.title_group);
208         mHeaderLayout = mLayoutView.findViewById(R.id.header_layout);
209         mHeaderTitle = mLayoutView.findViewById(R.id.header_title);
210         mHeaderSubtitle = mLayoutView.findViewById(R.id.header_subtitle);
211         mProgressBar = mLayoutView.findViewById(R.id.progress_bar);
212 
213         // Make the panel layout gone here, to avoid janky animation when updating from old panel.
214         // We will make it visible once the panel is ready to load.
215         mPanelSlices.setVisibility(View.GONE);
216 
217         final Bundle arguments = getArguments();
218         final String callingPackageName =
219                 arguments.getString(SettingsPanelActivity.KEY_CALLING_PACKAGE_NAME);
220 
221         mPanel = FeatureFactory.getFactory(activity)
222                 .getPanelFeatureProvider()
223                 .getPanel(activity, arguments);
224 
225         if (mPanel == null) {
226             activity.finish();
227             return;
228         }
229 
230         mPanel.registerCallback(new LocalPanelCallback());
231         if (mPanel instanceof LifecycleObserver) {
232             getLifecycle().addObserver((LifecycleObserver) mPanel);
233         }
234 
235         mMetricsProvider = FeatureFactory.getFactory(activity).getMetricsFeatureProvider();
236 
237         updateProgressBar();
238 
239         mPanelSlices.setLayoutManager(new LinearLayoutManager((activity)));
240         // Add predraw listener to remove the animation and while we wait for Slices to load.
241         mLayoutView.getViewTreeObserver().addOnPreDrawListener(mOnPreDrawListener);
242 
243         // Start loading Slices. When finished, the Panel will animate in.
244         loadAllSlices();
245 
246         final IconCompat icon = mPanel.getIcon();
247         final CharSequence title = mPanel.getTitle();
248         final CharSequence subtitle = mPanel.getSubTitle();
249 
250         if (icon != null || (subtitle != null && subtitle.length() > 0)) {
251             enablePanelHeader(icon, title, subtitle);
252         } else {
253             enableTitle(title);
254         }
255 
256         mSeeMoreButton.setOnClickListener(getSeeMoreListener());
257         mDoneButton.setOnClickListener(getCloseListener());
258 
259         if (mPanel.isCustomizedButtonUsed()) {
260             enableCustomizedButton();
261         } else if (mPanel.getSeeMoreIntent() == null) {
262             // If getSeeMoreIntent() is null hide the mSeeMoreButton.
263             mSeeMoreButton.setVisibility(View.GONE);
264         }
265 
266         // Log panel opened.
267         mMetricsProvider.action(
268                 0 /* attribution */,
269                 SettingsEnums.PAGE_VISIBLE /* opened panel - Action */,
270                 mPanel.getMetricsCategory(),
271                 callingPackageName,
272                 0 /* value */);
273     }
274 
enablePanelHeader(IconCompat icon, CharSequence title, CharSequence subtitle)275     private void enablePanelHeader(IconCompat icon, CharSequence title, CharSequence subtitle) {
276         mTitleView.setVisibility(View.GONE);
277         mPanelHeader.setVisibility(View.VISIBLE);
278         mPanelHeader.setAccessibilityPaneTitle(title);
279         mHeaderTitle.setText(title);
280         mHeaderSubtitle.setText(subtitle);
281         mHeaderSubtitle.setAccessibilityPaneTitle(subtitle);
282         if (icon != null) {
283             mTitleGroup.setVisibility(View.VISIBLE);
284             mHeaderLayout.setGravity(Gravity.LEFT);
285             mTitleIcon.setImageIcon(icon.toIcon(getContext()));
286             if (mPanel.getHeaderIconIntent() != null) {
287                 mTitleIcon.setOnClickListener(getHeaderIconListener());
288                 mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams(
289                         ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
290             } else {
291                 final int size = getResources().getDimensionPixelSize(
292                         R.dimen.output_switcher_panel_icon_size);
293                 mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams(size, size));
294             }
295         } else {
296             mTitleGroup.setVisibility(View.GONE);
297             mHeaderLayout.setGravity(Gravity.CENTER_HORIZONTAL);
298         }
299     }
300 
enableTitle(CharSequence title)301     private void enableTitle(CharSequence title) {
302         mPanelHeader.setVisibility(View.GONE);
303         mTitleView.setVisibility(View.VISIBLE);
304         mTitleView.setAccessibilityPaneTitle(title);
305         mTitleView.setText(title);
306     }
307 
enableCustomizedButton()308     private void enableCustomizedButton() {
309         final CharSequence customTitle = mPanel.getCustomizedButtonTitle();
310         if (TextUtils.isEmpty(customTitle)) {
311             mSeeMoreButton.setVisibility(View.GONE);
312         } else {
313             mSeeMoreButton.setVisibility(View.VISIBLE);
314             mSeeMoreButton.setText(customTitle);
315         }
316     }
317 
updateProgressBar()318     private void updateProgressBar() {
319         if (mPanel.isProgressBarVisible()) {
320             mProgressBar.setVisibility(View.VISIBLE);
321         } else {
322             mProgressBar.setVisibility(View.GONE);
323         }
324     }
325 
loadAllSlices()326     private void loadAllSlices() {
327         mSliceLiveData.clear();
328         final List<Uri> sliceUris = mPanel.getSlices();
329         mPanelSlicesLoaderCountdownLatch = new PanelSlicesLoaderCountdownLatch(sliceUris.size());
330 
331         for (Uri uri : sliceUris) {
332             final LiveData<Slice> sliceLiveData = SliceLiveData.fromUri(getActivity(), uri,
333                     (int type, Throwable source)-> {
334                             removeSliceLiveData(uri);
335                             mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
336                     });
337 
338             // Add slice first to make it in order.  Will remove it later if there's an error.
339             mSliceLiveData.put(uri, sliceLiveData);
340 
341             sliceLiveData.observe(getViewLifecycleOwner(), slice -> {
342                 // If the Slice has already loaded, do nothing.
343                 if (mPanelSlicesLoaderCountdownLatch.isSliceLoaded(uri)) {
344                     return;
345                 }
346 
347                 /**
348                  * Watching for the {@link Slice} to load.
349                  * <p>
350                  *     If the Slice comes back {@code null} or with the Error attribute, if slice
351                  *     uri is not in the allowlist, remove the Slice data from the list, otherwise
352                  *     keep the Slice data.
353                  * <p>
354                  *     If the Slice has come back fully loaded, then mark the Slice as loaded.  No
355                  *     other actions required since we already have the Slice data in the list.
356                  * <p>
357                  *     If the Slice does not match the above condition, we will still want to mark
358                  *     it as loaded after 250ms timeout to avoid delay showing up the panel for
359                  *     too long.  Since we are still having the Slice data in the list, the Slice
360                  *     will show up later once it is loaded.
361                  */
362                 final SliceMetadata metadata = SliceMetadata.from(getActivity(), slice);
363                 if (slice == null || metadata.isErrorSlice()) {
364                     removeSliceLiveData(uri);
365                     mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
366                 } else if (metadata.getLoadingState() == SliceMetadata.LOADED_ALL) {
367                     mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
368                 } else {
369                     Handler handler = new Handler();
370                     handler.postDelayed(() -> {
371                         mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
372                         loadPanelWhenReady();
373                     }, DURATION_SLICE_BINDING_TIMEOUT_MS);
374                 }
375 
376                 loadPanelWhenReady();
377             });
378         }
379     }
380 
removeSliceLiveData(Uri uri)381     private void removeSliceLiveData(Uri uri) {
382         final List<String> allowList = Arrays.asList(
383                 getResources().getStringArray(
384                         R.array.config_panel_keep_observe_uri));
385         if (!allowList.contains(uri.toString())) {
386             mSliceLiveData.remove(uri);
387         }
388     }
389 
390     /**
391      * When all of the Slices have loaded for the first time, then we can setup the
392      * {@link RecyclerView}.
393      * <p>
394      * When the Recyclerview has been laid out, we can begin the animation with the
395      * {@link mOnGlobalLayoutListener}, which calls {@link #animateIn()}.
396      */
loadPanelWhenReady()397     private void loadPanelWhenReady() {
398         if (mPanelSlicesLoaderCountdownLatch.isPanelReadyToLoad()) {
399             mAdapter = new PanelSlicesAdapter(
400                     this, mSliceLiveData, mPanel.getMetricsCategory());
401             mPanelSlices.setAdapter(mAdapter);
402             mPanelSlices.getViewTreeObserver()
403                     .addOnGlobalLayoutListener(mOnGlobalLayoutListener);
404             mPanelSlices.setVisibility(View.VISIBLE);
405 
406             final FragmentActivity activity = getActivity();
407             if (activity == null) {
408                 return;
409             }
410             final DividerItemDecoration itemDecoration = new DividerItemDecoration(activity);
411             itemDecoration
412                     .setDividerCondition(DividerItemDecoration.DIVIDER_CONDITION_BOTH);
413             if (mPanelSlices.getItemDecorationCount() == 0) {
414                 mPanelSlices.addItemDecoration(itemDecoration);
415             }
416         }
417     }
418 
419     /**
420      * Animate a Panel onto the screen.
421      * <p>
422      * Takes the entire panel and animates in from behind the navigation bar.
423      * <p>
424      * Relies on the Panel being having a fixed height to begin the animation.
425      */
animateIn()426     private void animateIn() {
427         final View panelContent = mLayoutView.findViewById(R.id.panel_container);
428         final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView,
429                 panelContent.getHeight() /* startY */, 0.0f /* endY */,
430                 0.0f /* startAlpha */, 1.0f /* endAlpha */,
431                 DURATION_ANIMATE_PANEL_EXPAND_MS);
432         final ValueAnimator animator = new ValueAnimator();
433         animator.setFloatValues(0.0f, 1.0f);
434         animatorSet.play(animator);
435         animatorSet.start();
436         // Remove the predraw listeners on the Panel.
437         mLayoutView.getViewTreeObserver().removeOnPreDrawListener(mOnPreDrawListener);
438     }
439 
440     /**
441      * Build an {@link AnimatorSet} to animate the Panel, {@param parentView} in or out of the
442      * screen, based on the positional parameters {@param startY}, {@param endY}, the parameters
443      * for alpha changes {@param startAlpha}, {@param endAlpha}, and the {@param duration} in
444      * milliseconds.
445      */
446     @NonNull
buildAnimatorSet(@onNull View parentView, float startY, float endY, float startAlpha, float endAlpha, int duration)447     private static AnimatorSet buildAnimatorSet(@NonNull View parentView, float startY, float endY,
448             float startAlpha, float endAlpha, int duration) {
449         final View sheet = parentView.findViewById(R.id.panel_container);
450         final AnimatorSet animatorSet = new AnimatorSet();
451         animatorSet.setDuration(duration);
452         animatorSet.setInterpolator(new DecelerateInterpolator());
453         animatorSet.playTogether(
454                 ObjectAnimator.ofFloat(sheet, View.TRANSLATION_Y, startY, endY),
455                 ObjectAnimator.ofFloat(sheet, View.ALPHA, startAlpha, endAlpha));
456         return animatorSet;
457     }
458 
459     @Override
onDestroyView()460     public void onDestroyView() {
461         super.onDestroyView();
462 
463         if (TextUtils.isEmpty(mPanelClosedKey)) {
464             mPanelClosedKey = PanelClosedKeys.KEY_OTHERS;
465         }
466 
467         if (mLayoutView != null) {
468             mLayoutView.getViewTreeObserver().removeOnGlobalLayoutListener(mPanelLayoutListener);
469         }
470         if (mPanel != null) {
471             mMetricsProvider.action(
472                     0 /* attribution */,
473                     SettingsEnums.PAGE_HIDE,
474                     mPanel.getMetricsCategory(),
475                     mPanelClosedKey,
476                     0 /* value */);
477         }
478     }
479 
480     @VisibleForTesting
getSeeMoreListener()481     View.OnClickListener getSeeMoreListener() {
482         return (v) -> {
483             mPanelClosedKey = PanelClosedKeys.KEY_SEE_MORE;
484             final FragmentActivity activity = getActivity();
485             if (mPanel.isCustomizedButtonUsed()) {
486                 mPanel.onClickCustomizedButton(activity);
487             } else {
488                 activity.startActivityForResult(mPanel.getSeeMoreIntent(), 0);
489                 activity.finish();
490             }
491         };
492     }
493 
494     @VisibleForTesting
getCloseListener()495     View.OnClickListener getCloseListener() {
496         return (v) -> {
497             mPanelClosedKey = PanelClosedKeys.KEY_DONE;
498             getActivity().finish();
499         };
500     }
501 
502     @VisibleForTesting
503     View.OnClickListener getHeaderIconListener() {
504         return (v) -> {
505             final FragmentActivity activity = getActivity();
506             activity.startActivity(mPanel.getHeaderIconIntent());
507         };
508     }
509 
510     int getPanelViewType() {
511         return mPanel.getViewType();
512     }
513 
514     class LocalPanelCallback implements PanelContentCallback {
515 
516         @Override
517         public void onCustomizedButtonStateChanged() {
518             ThreadUtils.postOnMainThread(() -> {
519                 enableCustomizedButton();
520             });
521         }
522 
523         @Override
524         public void onHeaderChanged() {
525             ThreadUtils.postOnMainThread(() -> {
526                 enablePanelHeader(mPanel.getIcon(), mPanel.getTitle(), mPanel.getSubTitle());
527             });
528         }
529 
530         @Override
531         public void forceClose() {
532             mPanelClosedKey = PanelClosedKeys.KEY_OTHERS;
533             getFragmentActivity().finish();
534         }
535 
536         @Override
537         public void onTitleChanged() {
538             ThreadUtils.postOnMainThread(() -> {
539                 enableTitle(mPanel.getTitle());
540             });
541         }
542 
543         @Override
544         public void onProgressBarVisibleChanged() {
545             ThreadUtils.postOnMainThread(() -> {
546                 updateProgressBar();
547             });
548         }
549 
550         @VisibleForTesting
551         FragmentActivity getFragmentActivity() {
552             return getActivity();
553         }
554     }
555 }
556