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