1 /* 2 * Copyright (C) 2017 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.keyguard; 18 19 import android.animation.LayoutTransition; 20 import android.animation.ObjectAnimator; 21 import android.animation.PropertyValuesHolder; 22 import android.annotation.ColorInt; 23 import android.app.PendingIntent; 24 import android.arch.lifecycle.LiveData; 25 import android.arch.lifecycle.Observer; 26 import android.content.Context; 27 import android.graphics.Color; 28 import android.graphics.drawable.Drawable; 29 import android.net.Uri; 30 import android.provider.Settings; 31 import android.text.Layout; 32 import android.text.TextUtils; 33 import android.text.TextUtils.TruncateAt; 34 import android.util.AttributeSet; 35 import android.util.Log; 36 import android.view.View; 37 import android.view.ViewGroup; 38 import android.view.animation.Animation; 39 import android.widget.Button; 40 import android.widget.LinearLayout; 41 import android.widget.TextView; 42 43 import com.android.internal.annotations.VisibleForTesting; 44 import com.android.internal.graphics.ColorUtils; 45 import com.android.settingslib.Utils; 46 import com.android.systemui.Dependency; 47 import com.android.systemui.Interpolators; 48 import com.android.systemui.R; 49 import com.android.systemui.keyguard.KeyguardSliceProvider; 50 import com.android.systemui.statusbar.AlphaOptimizedTextView; 51 import com.android.systemui.statusbar.policy.ConfigurationController; 52 import com.android.systemui.tuner.TunerService; 53 import com.android.systemui.util.wakelock.KeepAwakeAnimationListener; 54 55 import java.util.ArrayList; 56 import java.util.HashMap; 57 import java.util.List; 58 import java.util.function.Consumer; 59 60 import androidx.slice.Slice; 61 import androidx.slice.SliceItem; 62 import androidx.slice.SliceViewManager; 63 import androidx.slice.core.SliceQuery; 64 import androidx.slice.widget.ListContent; 65 import androidx.slice.widget.RowContent; 66 import androidx.slice.widget.SliceLiveData; 67 68 /** 69 * View visible under the clock on the lock screen and AoD. 70 */ 71 public class KeyguardSliceView extends LinearLayout implements View.OnClickListener, 72 Observer<Slice>, TunerService.Tunable, ConfigurationController.ConfigurationListener { 73 74 private static final String TAG = "KeyguardSliceView"; 75 public static final int DEFAULT_ANIM_DURATION = 550; 76 77 private final HashMap<View, PendingIntent> mClickActions; 78 private Uri mKeyguardSliceUri; 79 @VisibleForTesting 80 TextView mTitle; 81 private Row mRow; 82 private int mTextColor; 83 private float mDarkAmount = 0; 84 85 private LiveData<Slice> mLiveData; 86 private int mIconSize; 87 /** 88 * Runnable called whenever the view contents change. 89 */ 90 private Runnable mContentChangeListener; 91 private boolean mHasHeader; 92 private Slice mSlice; 93 private boolean mPulsing; 94 95 public KeyguardSliceView(Context context) { 96 this(context, null, 0); 97 } 98 99 public KeyguardSliceView(Context context, AttributeSet attrs) { 100 this(context, attrs, 0); 101 } 102 103 public KeyguardSliceView(Context context, AttributeSet attrs, int defStyle) { 104 super(context, attrs, defStyle); 105 106 TunerService tunerService = Dependency.get(TunerService.class); 107 tunerService.addTunable(this, Settings.Secure.KEYGUARD_SLICE_URI); 108 109 mClickActions = new HashMap<>(); 110 111 LayoutTransition transition = new LayoutTransition(); 112 transition.setStagger(LayoutTransition.CHANGE_APPEARING, DEFAULT_ANIM_DURATION / 2); 113 transition.setDuration(LayoutTransition.APPEARING, DEFAULT_ANIM_DURATION); 114 transition.setDuration(LayoutTransition.DISAPPEARING, DEFAULT_ANIM_DURATION / 2); 115 transition.disableTransitionType(LayoutTransition.CHANGE_APPEARING); 116 transition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING); 117 transition.setInterpolator(LayoutTransition.APPEARING, Interpolators.FAST_OUT_SLOW_IN); 118 transition.setInterpolator(LayoutTransition.DISAPPEARING, Interpolators.ALPHA_OUT); 119 transition.setAnimateParentHierarchy(false); 120 transition.addTransitionListener(new SliceViewTransitionListener()); 121 setLayoutTransition(transition); 122 } 123 124 @Override 125 protected void onFinishInflate() { 126 super.onFinishInflate(); 127 mTitle = findViewById(R.id.title); 128 mRow = findViewById(R.id.row); 129 mTextColor = Utils.getColorAttr(mContext, R.attr.wallpaperTextColor); 130 } 131 132 @Override 133 protected void onAttachedToWindow() { 134 super.onAttachedToWindow(); 135 136 // Make sure we always have the most current slice 137 mLiveData.observeForever(this); 138 Dependency.get(ConfigurationController.class).addCallback(this); 139 } 140 141 @Override 142 protected void onDetachedFromWindow() { 143 super.onDetachedFromWindow(); 144 145 mLiveData.removeObserver(this); 146 Dependency.get(ConfigurationController.class).removeCallback(this); 147 } 148 149 private void showSlice() { 150 if (mPulsing || mSlice == null) { 151 mTitle.setVisibility(GONE); 152 mRow.setVisibility(GONE); 153 if (mContentChangeListener != null) { 154 mContentChangeListener.run(); 155 } 156 return; 157 } 158 159 ListContent lc = new ListContent(getContext(), mSlice); 160 mHasHeader = lc.hasHeader(); 161 List<SliceItem> subItems = new ArrayList<SliceItem>(); 162 for (int i = 0; i < lc.getRowItems().size(); i++) { 163 SliceItem subItem = lc.getRowItems().get(i); 164 String itemUri = subItem.getSlice().getUri().toString(); 165 // Filter out the action row 166 if (!KeyguardSliceProvider.KEYGUARD_ACTION_URI.equals(itemUri)) { 167 subItems.add(subItem); 168 } 169 } 170 if (!mHasHeader) { 171 mTitle.setVisibility(GONE); 172 } else { 173 mTitle.setVisibility(VISIBLE); 174 175 // If there's a header it'll be the first subitem 176 RowContent header = new RowContent(getContext(), subItems.get(0), 177 true /* showStartItem */); 178 SliceItem mainTitle = header.getTitleItem(); 179 CharSequence title = mainTitle != null ? mainTitle.getText() : null; 180 mTitle.setText(title); 181 } 182 183 mClickActions.clear(); 184 final int subItemsCount = subItems.size(); 185 final int blendedColor = getTextColor(); 186 final int startIndex = mHasHeader ? 1 : 0; // First item is header; skip it 187 mRow.setVisibility(subItemsCount > 0 ? VISIBLE : GONE); 188 for (int i = startIndex; i < subItemsCount; i++) { 189 SliceItem item = subItems.get(i); 190 RowContent rc = new RowContent(getContext(), item, true /* showStartItem */); 191 final Uri itemTag = item.getSlice().getUri(); 192 // Try to reuse the view if already exists in the layout 193 KeyguardSliceButton button = mRow.findViewWithTag(itemTag); 194 if (button == null) { 195 button = new KeyguardSliceButton(mContext); 196 button.setTextColor(blendedColor); 197 button.setTag(itemTag); 198 final int viewIndex = i - (mHasHeader ? 1 : 0); 199 mRow.addView(button, viewIndex); 200 } 201 202 PendingIntent pendingIntent = null; 203 if (rc.getPrimaryAction() != null) { 204 pendingIntent = rc.getPrimaryAction().getAction(); 205 } 206 mClickActions.put(button, pendingIntent); 207 208 final SliceItem titleItem = rc.getTitleItem(); 209 button.setText(titleItem == null ? null : titleItem.getText()); 210 button.setContentDescription(rc.getContentDescription()); 211 212 Drawable iconDrawable = null; 213 SliceItem icon = SliceQuery.find(item.getSlice(), 214 android.app.slice.SliceItem.FORMAT_IMAGE); 215 if (icon != null) { 216 iconDrawable = icon.getIcon().loadDrawable(mContext); 217 final int width = (int) (iconDrawable.getIntrinsicWidth() 218 / (float) iconDrawable.getIntrinsicHeight() * mIconSize); 219 iconDrawable.setBounds(0, 0, Math.max(width, 1), mIconSize); 220 } 221 button.setCompoundDrawables(iconDrawable, null, null, null); 222 button.setOnClickListener(this); 223 button.setClickable(pendingIntent != null); 224 } 225 226 // Removing old views 227 for (int i = 0; i < mRow.getChildCount(); i++) { 228 View child = mRow.getChildAt(i); 229 if (!mClickActions.containsKey(child)) { 230 mRow.removeView(child); 231 i--; 232 } 233 } 234 235 if (mContentChangeListener != null) { 236 mContentChangeListener.run(); 237 } 238 } 239 240 public void setPulsing(boolean pulsing, boolean animate) { 241 mPulsing = pulsing; 242 LayoutTransition transition = getLayoutTransition(); 243 if (!animate) { 244 setLayoutTransition(null); 245 } 246 showSlice(); 247 if (!animate) { 248 setLayoutTransition(transition); 249 } 250 } 251 252 /** 253 * Breaks a string in 2 lines where both have similar character count 254 * but first line is always longer. 255 * 256 * @param charSequence Original text. 257 * @return Optimal string. 258 */ 259 private static CharSequence findBestLineBreak(CharSequence charSequence) { 260 if (TextUtils.isEmpty(charSequence)) { 261 return charSequence; 262 } 263 264 String source = charSequence.toString(); 265 // Ignore if there is only 1 word, 266 // or if line breaks were manually set. 267 if (source.contains(" ") || !source.contains(" ")) { 268 return source; 269 } 270 271 final String[] words = source.split(" "); 272 final StringBuilder optimalString = new StringBuilder(source.length()); 273 int current = 0; 274 while (optimalString.length() < source.length() - optimalString.length()) { 275 optimalString.append(words[current]); 276 if (current < words.length - 1) { 277 optimalString.append(" "); 278 } 279 current++; 280 } 281 optimalString.append(" "); 282 for (int i = current; i < words.length; i++) { 283 optimalString.append(words[i]); 284 if (current < words.length - 1) { 285 optimalString.append(" "); 286 } 287 } 288 289 return optimalString.toString(); 290 } 291 292 public void setDarkAmount(float darkAmount) { 293 mDarkAmount = darkAmount; 294 mRow.setDarkAmount(darkAmount); 295 updateTextColors(); 296 } 297 298 private void updateTextColors() { 299 final int blendedColor = getTextColor(); 300 mTitle.setTextColor(blendedColor); 301 int childCount = mRow.getChildCount(); 302 for (int i = 0; i < childCount; i++) { 303 View v = mRow.getChildAt(i); 304 if (v instanceof Button) { 305 ((Button) v).setTextColor(blendedColor); 306 } 307 } 308 } 309 310 @Override 311 public void onClick(View v) { 312 final PendingIntent action = mClickActions.get(v); 313 if (action != null) { 314 try { 315 action.send(); 316 } catch (PendingIntent.CanceledException e) { 317 Log.i(TAG, "Pending intent cancelled, nothing to launch", e); 318 } 319 } 320 } 321 322 /** 323 * Runnable that gets invoked every time the title or the row visibility changes. 324 * @param contentChangeListener The listener. 325 */ 326 public void setContentChangeListener(Runnable contentChangeListener) { 327 mContentChangeListener = contentChangeListener; 328 } 329 330 public boolean hasHeader() { 331 return mHasHeader; 332 } 333 334 /** 335 * LiveData observer lifecycle. 336 * @param slice the new slice content. 337 */ 338 @Override 339 public void onChanged(Slice slice) { 340 mSlice = slice; 341 showSlice(); 342 } 343 344 @Override 345 public void onTuningChanged(String key, String newValue) { 346 setupUri(newValue); 347 } 348 349 public void setupUri(String uriString) { 350 if (uriString == null) { 351 uriString = KeyguardSliceProvider.KEYGUARD_SLICE_URI; 352 } 353 354 boolean wasObserving = false; 355 if (mLiveData != null && mLiveData.hasActiveObservers()) { 356 wasObserving = true; 357 mLiveData.removeObserver(this); 358 } 359 360 mKeyguardSliceUri = Uri.parse(uriString); 361 mLiveData = SliceLiveData.fromUri(mContext, mKeyguardSliceUri); 362 363 if (wasObserving) { 364 mLiveData.observeForever(this); 365 } 366 } 367 368 @VisibleForTesting 369 int getTextColor() { 370 return ColorUtils.blendARGB(mTextColor, Color.WHITE, mDarkAmount); 371 } 372 373 @VisibleForTesting 374 void setTextColor(@ColorInt int textColor) { 375 mTextColor = textColor; 376 updateTextColors(); 377 } 378 379 @Override 380 public void onDensityOrFontScaleChanged() { 381 mIconSize = mContext.getResources().getDimensionPixelSize(R.dimen.widget_icon_size); 382 } 383 384 public void refresh() { 385 Slice slice = SliceViewManager.getInstance(getContext()).bindSlice(mKeyguardSliceUri); 386 onChanged(slice); 387 } 388 389 public static class Row extends LinearLayout { 390 391 /** 392 * This view is visible in AOD, which means that the device will sleep if we 393 * don't hold a wake lock. We want to enter doze only after all views have reached 394 * their desired positions. 395 */ 396 private final Animation.AnimationListener mKeepAwakeListener; 397 private float mDarkAmount; 398 399 public Row(Context context) { 400 this(context, null); 401 } 402 403 public Row(Context context, AttributeSet attrs) { 404 this(context, attrs, 0); 405 } 406 407 public Row(Context context, AttributeSet attrs, int defStyleAttr) { 408 this(context, attrs, defStyleAttr, 0); 409 } 410 411 public Row(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 412 super(context, attrs, defStyleAttr, defStyleRes); 413 mKeepAwakeListener = new KeepAwakeAnimationListener(mContext); 414 } 415 416 @Override 417 protected void onFinishInflate() { 418 LayoutTransition transition = new LayoutTransition(); 419 transition.setDuration(DEFAULT_ANIM_DURATION); 420 421 PropertyValuesHolder left = PropertyValuesHolder.ofInt("left", 0, 1); 422 PropertyValuesHolder right = PropertyValuesHolder.ofInt("right", 0, 1); 423 ObjectAnimator changeAnimator = ObjectAnimator.ofPropertyValuesHolder((Object) null, 424 left, right); 425 transition.setAnimator(LayoutTransition.CHANGE_APPEARING, changeAnimator); 426 transition.setAnimator(LayoutTransition.CHANGE_DISAPPEARING, changeAnimator); 427 transition.setInterpolator(LayoutTransition.CHANGE_APPEARING, 428 Interpolators.ACCELERATE_DECELERATE); 429 transition.setInterpolator(LayoutTransition.CHANGE_DISAPPEARING, 430 Interpolators.ACCELERATE_DECELERATE); 431 transition.setStartDelay(LayoutTransition.CHANGE_APPEARING, DEFAULT_ANIM_DURATION); 432 transition.setStartDelay(LayoutTransition.CHANGE_DISAPPEARING, DEFAULT_ANIM_DURATION); 433 434 ObjectAnimator appearAnimator = ObjectAnimator.ofFloat(null, "alpha", 0f, 1f); 435 transition.setAnimator(LayoutTransition.APPEARING, appearAnimator); 436 transition.setInterpolator(LayoutTransition.APPEARING, Interpolators.ALPHA_IN); 437 438 ObjectAnimator disappearAnimator = ObjectAnimator.ofFloat(null, "alpha", 1f, 0f); 439 transition.setInterpolator(LayoutTransition.DISAPPEARING, Interpolators.ALPHA_OUT); 440 transition.setDuration(LayoutTransition.DISAPPEARING, DEFAULT_ANIM_DURATION / 4); 441 transition.setAnimator(LayoutTransition.DISAPPEARING, disappearAnimator); 442 443 transition.setAnimateParentHierarchy(false); 444 setLayoutTransition(transition); 445 } 446 447 @Override 448 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 449 int width = MeasureSpec.getSize(widthMeasureSpec); 450 int childCount = getChildCount(); 451 for (int i = 0; i < childCount; i++) { 452 View child = getChildAt(i); 453 if (child instanceof KeyguardSliceButton) { 454 ((KeyguardSliceButton) child).setMaxWidth(width / childCount); 455 } 456 } 457 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 458 } 459 460 public void setDarkAmount(float darkAmount) { 461 boolean isAwake = darkAmount != 0; 462 boolean wasAwake = mDarkAmount != 0; 463 if (isAwake == wasAwake) { 464 return; 465 } 466 mDarkAmount = darkAmount; 467 setLayoutAnimationListener(isAwake ? null : mKeepAwakeListener); 468 } 469 470 @Override 471 public boolean hasOverlappingRendering() { 472 return false; 473 } 474 } 475 476 /** 477 * Representation of an item that appears under the clock on main keyguard message. 478 */ 479 @VisibleForTesting 480 static class KeyguardSliceButton extends Button implements 481 ConfigurationController.ConfigurationListener { 482 483 public KeyguardSliceButton(Context context) { 484 super(context, null /* attrs */, 0 /* styleAttr */, 485 com.android.keyguard.R.style.TextAppearance_Keyguard_Secondary); 486 onDensityOrFontScaleChanged(); 487 setEllipsize(TruncateAt.END); 488 } 489 490 @Override 491 protected void onAttachedToWindow() { 492 super.onAttachedToWindow(); 493 Dependency.get(ConfigurationController.class).addCallback(this); 494 } 495 496 @Override 497 protected void onDetachedFromWindow() { 498 super.onDetachedFromWindow(); 499 Dependency.get(ConfigurationController.class).removeCallback(this); 500 } 501 502 @Override 503 public void onDensityOrFontScaleChanged() { 504 updatePadding(); 505 } 506 507 @Override 508 public void setText(CharSequence text, BufferType type) { 509 super.setText(text, type); 510 updatePadding(); 511 } 512 513 private void updatePadding() { 514 boolean hasText = !TextUtils.isEmpty(getText()); 515 int horizontalPadding = (int) getContext().getResources() 516 .getDimension(R.dimen.widget_horizontal_padding) / 2; 517 setPadding(horizontalPadding, 0, horizontalPadding * (hasText ? 1 : -1), 0); 518 setCompoundDrawablePadding((int) mContext.getResources() 519 .getDimension(R.dimen.widget_icon_padding)); 520 } 521 522 @Override 523 public void setTextColor(int color) { 524 super.setTextColor(color); 525 updateDrawableColors(); 526 } 527 528 @Override 529 public void setCompoundDrawables(Drawable left, Drawable top, Drawable right, 530 Drawable bottom) { 531 super.setCompoundDrawables(left, top, right, bottom); 532 updateDrawableColors(); 533 updatePadding(); 534 } 535 536 private void updateDrawableColors() { 537 final int color = getCurrentTextColor(); 538 for (Drawable drawable : getCompoundDrawables()) { 539 if (drawable != null) { 540 drawable.setTint(color); 541 } 542 } 543 } 544 } 545 546 /** 547 * A text view that will split its contents in 2 lines when possible. 548 */ 549 static class TitleView extends AlphaOptimizedTextView { 550 551 public TitleView(Context context) { 552 super(context); 553 } 554 555 public TitleView(Context context, AttributeSet attrs) { 556 super(context, attrs); 557 } 558 559 public TitleView(Context context, AttributeSet attrs, int defStyleAttr) { 560 super(context, attrs, defStyleAttr); 561 } 562 563 public TitleView(Context context, AttributeSet attrs, int defStyleAttr, 564 int defStyleRes) { 565 super(context, attrs, defStyleAttr, defStyleRes); 566 } 567 568 @Override 569 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 570 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 571 572 Layout layout = getLayout(); 573 int lineCount = layout.getLineCount(); 574 boolean ellipsizing = layout.getEllipsisCount(lineCount - 1) != 0; 575 if (lineCount > 0 && !ellipsizing) { 576 CharSequence title = getText(); 577 CharSequence bestLineBreak = findBestLineBreak(title); 578 if (!TextUtils.equals(title, bestLineBreak)) { 579 setText(bestLineBreak); 580 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 581 } 582 } 583 } 584 } 585 586 private class SliceViewTransitionListener implements LayoutTransition.TransitionListener { 587 @Override 588 public void startTransition(LayoutTransition transition, ViewGroup container, View view, 589 int transitionType) { 590 switch (transitionType) { 591 case LayoutTransition.APPEARING: 592 int translation = getResources().getDimensionPixelSize( 593 R.dimen.pulsing_notification_appear_translation); 594 view.setTranslationY(translation); 595 view.animate() 596 .translationY(0) 597 .setDuration(DEFAULT_ANIM_DURATION) 598 .setInterpolator(Interpolators.ALPHA_IN) 599 .start(); 600 break; 601 case LayoutTransition.DISAPPEARING: 602 if (view == mTitle) { 603 // Translate the view to the inverse of its height, so the layout event 604 // won't misposition it. 605 LayoutParams params = (LayoutParams) mTitle.getLayoutParams(); 606 int margin = params.topMargin + params.bottomMargin; 607 mTitle.setTranslationY(-mTitle.getHeight() - margin); 608 } 609 break; 610 } 611 } 612 613 @Override 614 public void endTransition(LayoutTransition transition, ViewGroup container, View view, 615 int transitionType) { 616 617 } 618 } 619 }