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