1 /* 2 * Copyright (C) 2024 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.systemui.statusbar.chips.ui.binder 18 19 import android.annotation.IdRes 20 import android.content.Context 21 import android.content.res.ColorStateList 22 import android.graphics.Typeface 23 import android.graphics.drawable.GradientDrawable 24 import android.view.View 25 import android.view.ViewGroup 26 import android.widget.DateTimeView 27 import android.widget.FrameLayout 28 import android.widget.ImageView 29 import android.widget.TextView 30 import androidx.annotation.UiThread 31 import com.android.systemui.FontStyles 32 import com.android.systemui.common.shared.model.ContentDescription 33 import com.android.systemui.common.ui.binder.ContentDescriptionViewBinder 34 import com.android.systemui.common.ui.binder.IconViewBinder 35 import com.android.systemui.res.R 36 import com.android.systemui.statusbar.StatusBarIconView 37 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips 38 import com.android.systemui.statusbar.chips.ui.model.ColorsModel 39 import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel 40 import com.android.systemui.statusbar.chips.ui.view.ChipBackgroundContainer 41 import com.android.systemui.statusbar.chips.ui.view.ChipChronometer 42 import com.android.systemui.statusbar.core.StatusBarConnectedDisplays 43 import com.android.systemui.statusbar.notification.icon.ui.viewbinder.NotificationIconContainerViewBinder.IconViewStore 44 45 /** Binder for ongoing activity chip views. */ 46 object OngoingActivityChipBinder { 47 /** Binds the given [chipModel] data to the given [chipView]. */ bindnull48 fun bind( 49 chipModel: OngoingActivityChipModel, 50 viewBinding: OngoingActivityChipViewBinding, 51 iconViewStore: IconViewStore?, 52 ) { 53 val chipContext = viewBinding.rootView.context 54 val chipDefaultIconView = viewBinding.defaultIconView 55 val chipTimeView = viewBinding.timeView 56 val chipTextView = viewBinding.textView 57 val chipShortTimeDeltaView = viewBinding.shortTimeDeltaView 58 val chipBackgroundView = viewBinding.backgroundView 59 60 when (chipModel) { 61 is OngoingActivityChipModel.Active -> { 62 // Data 63 setChipIcon(chipModel, chipBackgroundView, chipDefaultIconView, iconViewStore) 64 setChipMainContent(chipModel, chipTextView, chipTimeView, chipShortTimeDeltaView) 65 66 viewBinding.rootView.setOnClickListener(chipModel.onClickListenerLegacy) 67 updateChipPadding( 68 chipModel, 69 chipBackgroundView, 70 chipTextView, 71 chipTimeView, 72 chipShortTimeDeltaView, 73 ) 74 75 // Accessibility 76 setChipAccessibility(chipModel, viewBinding.rootView, chipBackgroundView) 77 78 // Colors 79 val textColor = chipModel.colors.text(chipContext) 80 chipTimeView.setTextColor(textColor) 81 chipTextView.setTextColor(textColor) 82 chipShortTimeDeltaView.setTextColor(textColor) 83 (chipBackgroundView.background as GradientDrawable).setBackgroundColors( 84 chipModel.colors, 85 chipContext, 86 ) 87 } 88 is OngoingActivityChipModel.Inactive -> { 89 // The Chronometer should be stopped to prevent leaks -- see b/192243808 and 90 // [Chronometer.start]. 91 chipTimeView.stop() 92 } 93 } 94 } 95 96 /** Stores [rootView] and relevant child views in an object for easy reference. */ createBindingnull97 fun createBinding(rootView: View): OngoingActivityChipViewBinding { 98 return OngoingActivityChipViewBinding( 99 rootView = rootView, 100 timeView = rootView.requireViewById(R.id.ongoing_activity_chip_time), 101 textView = rootView.requireViewById(R.id.ongoing_activity_chip_text), 102 shortTimeDeltaView = 103 rootView.requireViewById(R.id.ongoing_activity_chip_short_time_delta), 104 defaultIconView = rootView.requireViewById(R.id.ongoing_activity_chip_icon), 105 backgroundView = rootView.requireViewById(R.id.ongoing_activity_chip_background), 106 ) 107 } 108 109 /** 110 * Resets any width restrictions that were placed on the primary chip's contents. 111 * 112 * Should be used when the user's screen bounds changed because there may now be more room in 113 * the status bar to show additional content. 114 */ resetPrimaryChipWidthRestrictionsnull115 fun resetPrimaryChipWidthRestrictions( 116 primaryChipViewBinding: OngoingActivityChipViewBinding, 117 currentPrimaryChipViewModel: OngoingActivityChipModel, 118 ) { 119 if (currentPrimaryChipViewModel is OngoingActivityChipModel.Inactive) { 120 return 121 } 122 resetChipMainContentWidthRestrictions( 123 primaryChipViewBinding, 124 currentPrimaryChipViewModel as OngoingActivityChipModel.Active, 125 ) 126 } 127 128 /** 129 * Resets any width restrictions that were placed on the secondary chip and its contents. 130 * 131 * Should be used when the user's screen bounds changed because there may now be more room in 132 * the status bar to show additional content. 133 */ resetSecondaryChipWidthRestrictionsnull134 fun resetSecondaryChipWidthRestrictions( 135 secondaryChipViewBinding: OngoingActivityChipViewBinding, 136 currentSecondaryChipModel: OngoingActivityChipModel, 137 ) { 138 if (currentSecondaryChipModel is OngoingActivityChipModel.Inactive) { 139 return 140 } 141 secondaryChipViewBinding.rootView.resetWidthRestriction() 142 resetChipMainContentWidthRestrictions( 143 secondaryChipViewBinding, 144 currentSecondaryChipModel as OngoingActivityChipModel.Active, 145 ) 146 } 147 resetChipMainContentWidthRestrictionsnull148 private fun resetChipMainContentWidthRestrictions( 149 viewBinding: OngoingActivityChipViewBinding, 150 model: OngoingActivityChipModel.Active, 151 ) { 152 when (model) { 153 is OngoingActivityChipModel.Active.Text -> viewBinding.textView.resetWidthRestriction() 154 is OngoingActivityChipModel.Active.Timer -> viewBinding.timeView.resetWidthRestriction() 155 is OngoingActivityChipModel.Active.ShortTimeDelta -> 156 viewBinding.shortTimeDeltaView.resetWidthRestriction() 157 is OngoingActivityChipModel.Active.IconOnly, 158 is OngoingActivityChipModel.Active.Countdown -> {} 159 } 160 } 161 162 /** 163 * Resets any width restrictions that were placed on the given view. 164 * 165 * Should be used when the user's screen bounds changed because there may now be more room in 166 * the status bar to show additional content. 167 */ 168 @UiThread resetWidthRestrictionnull169 fun View.resetWidthRestriction() { 170 // View needs to be visible in order to be re-measured 171 visibility = View.VISIBLE 172 forceLayout() 173 } 174 175 /** Updates the typefaces for any text shown in the chip. */ updateTypefacesnull176 fun updateTypefaces(binding: OngoingActivityChipViewBinding) { 177 binding.timeView.typeface = 178 Typeface.create(FontStyles.GSF_LABEL_LARGE_EMPHASIZED, Typeface.NORMAL) 179 binding.textView.typeface = 180 Typeface.create(FontStyles.GSF_LABEL_LARGE_EMPHASIZED, Typeface.NORMAL) 181 binding.shortTimeDeltaView.typeface = 182 Typeface.create(FontStyles.GSF_LABEL_LARGE_EMPHASIZED, Typeface.NORMAL) 183 } 184 setChipIconnull185 private fun setChipIcon( 186 chipModel: OngoingActivityChipModel.Active, 187 backgroundView: ChipBackgroundContainer, 188 defaultIconView: ImageView, 189 iconViewStore: IconViewStore?, 190 ) { 191 // Always remove any previously set custom icon. If we have a new custom icon, we'll re-add 192 // it. 193 backgroundView.removeView(backgroundView.getCustomIconView()) 194 195 val iconTint = chipModel.colors.text(defaultIconView.context) 196 197 when (val icon = chipModel.icon) { 198 null -> { 199 defaultIconView.visibility = View.GONE 200 } 201 is OngoingActivityChipModel.ChipIcon.SingleColorIcon -> { 202 IconViewBinder.bind(icon.impl, defaultIconView) 203 defaultIconView.visibility = View.VISIBLE 204 defaultIconView.tintView(iconTint) 205 } 206 is OngoingActivityChipModel.ChipIcon.StatusBarView -> { 207 StatusBarConnectedDisplays.assertInLegacyMode() 208 setStatusBarIconView( 209 defaultIconView, 210 icon.impl, 211 icon.contentDescription, 212 iconTint, 213 backgroundView, 214 ) 215 } 216 is OngoingActivityChipModel.ChipIcon.StatusBarNotificationIcon -> { 217 StatusBarConnectedDisplays.unsafeAssertInNewMode() 218 val iconView = fetchStatusBarIconView(iconViewStore, icon) 219 if (iconView == null) { 220 // This means that the notification key doesn't exist anymore. 221 return 222 } 223 setStatusBarIconView( 224 defaultIconView, 225 iconView, 226 icon.contentDescription, 227 iconTint, 228 backgroundView, 229 ) 230 } 231 } 232 } 233 fetchStatusBarIconViewnull234 private fun fetchStatusBarIconView( 235 iconViewStore: IconViewStore?, 236 icon: OngoingActivityChipModel.ChipIcon.StatusBarNotificationIcon, 237 ): StatusBarIconView? { 238 StatusBarConnectedDisplays.unsafeAssertInNewMode() 239 if (iconViewStore == null) { 240 throw IllegalStateException("Store should always be non-null when flag is enabled.") 241 } 242 return iconViewStore.iconView(icon.notificationKey) 243 } 244 setStatusBarIconViewnull245 private fun setStatusBarIconView( 246 defaultIconView: ImageView, 247 iconView: StatusBarIconView, 248 iconContentDescription: ContentDescription, 249 iconTint: Int, 250 backgroundView: ChipBackgroundContainer, 251 ) { 252 // Hide the default icon since we'll show this custom icon instead. 253 defaultIconView.visibility = View.GONE 254 255 // 1. Set up the right visual params. 256 with(iconView) { 257 id = CUSTOM_ICON_VIEW_ID 258 if (StatusBarNotifChips.isEnabled) { 259 ContentDescriptionViewBinder.bind(iconContentDescription, this) 260 } else { 261 contentDescription = 262 context.resources.getString(R.string.ongoing_call_content_description) 263 } 264 tintView(iconTint) 265 } 266 267 // 2. If we just reinflated the view, we may need to detach the icon view from the old chip 268 // before we reattach it to the new one. 269 // See also: NotificationIconContainerViewBinder#bindIcons. 270 val currentParent = iconView.parent as? ViewGroup 271 if (currentParent != null && currentParent != backgroundView) { 272 currentParent.removeView(iconView) 273 currentParent.removeTransientView(iconView) 274 } 275 276 // 3: Add the icon as the starting view. 277 backgroundView.addView(iconView, /* index= */ 0, generateCustomIconLayoutParams(iconView)) 278 } 279 getCustomIconViewnull280 private fun View.getCustomIconView(): StatusBarIconView? { 281 return this.findViewById(CUSTOM_ICON_VIEW_ID) 282 } 283 tintViewnull284 private fun ImageView.tintView(color: Int) { 285 this.imageTintList = ColorStateList.valueOf(color) 286 } 287 generateCustomIconLayoutParamsnull288 private fun generateCustomIconLayoutParams(iconView: ImageView): FrameLayout.LayoutParams { 289 val customIconSize = 290 iconView.context.resources.getDimensionPixelSize( 291 R.dimen.ongoing_activity_chip_embedded_padding_icon_size 292 ) 293 return FrameLayout.LayoutParams(customIconSize, customIconSize) 294 } 295 setChipMainContentnull296 private fun setChipMainContent( 297 chipModel: OngoingActivityChipModel.Active, 298 chipTextView: TextView, 299 chipTimeView: ChipChronometer, 300 chipShortTimeDeltaView: DateTimeView, 301 ) { 302 when (chipModel) { 303 is OngoingActivityChipModel.Active.Countdown -> { 304 chipTextView.text = chipModel.secondsUntilStarted.toString() 305 chipTextView.visibility = View.VISIBLE 306 307 chipTimeView.hide() 308 chipShortTimeDeltaView.visibility = View.GONE 309 } 310 is OngoingActivityChipModel.Active.Text -> { 311 chipTextView.text = chipModel.text 312 chipTextView.visibility = View.VISIBLE 313 314 chipTimeView.hide() 315 chipShortTimeDeltaView.visibility = View.GONE 316 } 317 is OngoingActivityChipModel.Active.Timer -> { 318 ChipChronometerBinder.bind( 319 chipModel.startTimeMs, 320 chipModel.isEventInFuture, 321 chipTimeView, 322 ) 323 chipTimeView.visibility = View.VISIBLE 324 325 chipTextView.visibility = View.GONE 326 chipShortTimeDeltaView.visibility = View.GONE 327 } 328 is OngoingActivityChipModel.Active.ShortTimeDelta -> { 329 chipShortTimeDeltaView.setTime(chipModel.time) 330 chipShortTimeDeltaView.visibility = View.VISIBLE 331 chipShortTimeDeltaView.isShowRelativeTime = true 332 chipShortTimeDeltaView.setRelativeTimeDisambiguationTextMask( 333 DateTimeView.DISAMBIGUATION_TEXT_PAST 334 ) 335 chipShortTimeDeltaView.setRelativeTimeUnitDisplayLength( 336 DateTimeView.UNIT_DISPLAY_LENGTH_MEDIUM 337 ) 338 339 chipTextView.visibility = View.GONE 340 chipTimeView.hide() 341 } 342 is OngoingActivityChipModel.Active.IconOnly -> { 343 chipTextView.visibility = View.GONE 344 chipShortTimeDeltaView.visibility = View.GONE 345 chipTimeView.hide() 346 } 347 } 348 } 349 hidenull350 private fun ChipChronometer.hide() { 351 // The Chronometer should be stopped to prevent leaks -- see b/192243808 and 352 // [Chronometer.start]. 353 this.stop() 354 this.visibility = View.GONE 355 } 356 updateChipPaddingnull357 private fun updateChipPadding( 358 chipModel: OngoingActivityChipModel.Active, 359 backgroundView: View, 360 chipTextView: TextView, 361 chipTimeView: ChipChronometer, 362 chipShortTimeDeltaView: DateTimeView, 363 ) { 364 val icon = chipModel.icon 365 if (icon != null) { 366 if (iconRequiresEmbeddedPadding(icon)) { 367 // If the icon is a custom [StatusBarIconView], then it should've come from 368 // `Notification.smallIcon`, which is required to embed its own paddings. We need to 369 // adjust the other paddings to make everything look good :) 370 backgroundView.setBackgroundPaddingForEmbeddedPaddingIcon() 371 chipTextView.setTextPaddingForEmbeddedPaddingIcon() 372 chipTimeView.setTextPaddingForEmbeddedPaddingIcon() 373 chipShortTimeDeltaView.setTextPaddingForEmbeddedPaddingIcon() 374 } else { 375 backgroundView.setBackgroundPaddingForNormalIcon() 376 chipTextView.setTextPaddingForNormalIcon() 377 chipTimeView.setTextPaddingForNormalIcon() 378 chipShortTimeDeltaView.setTextPaddingForNormalIcon() 379 } 380 } else { 381 backgroundView.setBackgroundPaddingForNoIcon() 382 chipTextView.setTextPaddingForNoIcon() 383 chipTimeView.setTextPaddingForNoIcon() 384 chipShortTimeDeltaView.setTextPaddingForNoIcon() 385 } 386 } 387 iconRequiresEmbeddedPaddingnull388 private fun iconRequiresEmbeddedPadding(icon: OngoingActivityChipModel.ChipIcon) = 389 icon is OngoingActivityChipModel.ChipIcon.StatusBarView || 390 icon is OngoingActivityChipModel.ChipIcon.StatusBarNotificationIcon 391 392 private fun View.setTextPaddingForEmbeddedPaddingIcon() { 393 val newPaddingEnd = 394 context.resources.getDimensionPixelSize( 395 R.dimen.ongoing_activity_chip_text_end_padding_for_embedded_padding_icon 396 ) 397 setPaddingRelative( 398 // The icon should embed enough padding between the icon and time view. 399 /* start= */ 0, 400 this.paddingTop, 401 newPaddingEnd, 402 this.paddingBottom, 403 ) 404 } 405 setTextPaddingForNormalIconnull406 private fun View.setTextPaddingForNormalIcon() { 407 this.setPaddingRelative( 408 this.context.resources.getDimensionPixelSize( 409 R.dimen.ongoing_activity_chip_icon_text_padding 410 ), 411 paddingTop, 412 // The background view will contain the right end padding. 413 /* end= */ 0, 414 paddingBottom, 415 ) 416 } 417 setTextPaddingForNoIconnull418 private fun View.setTextPaddingForNoIcon() { 419 // The background view will have even start & end paddings, so we don't want the text view 420 // to add any additional padding. 421 this.setPaddingRelative(/* start= */ 0, paddingTop, /* end= */ 0, paddingBottom) 422 } 423 setBackgroundPaddingForEmbeddedPaddingIconnull424 private fun View.setBackgroundPaddingForEmbeddedPaddingIcon() { 425 val sidePadding = 426 if (StatusBarNotifChips.isEnabled) { 427 context.resources.getDimensionPixelSize( 428 R.dimen.ongoing_activity_chip_side_padding_for_embedded_padding_icon 429 ) 430 } else { 431 context.resources.getDimensionPixelSize( 432 R.dimen.ongoing_activity_chip_side_padding_for_embedded_padding_icon_legacy 433 ) 434 } 435 setPaddingRelative(sidePadding, paddingTop, sidePadding, paddingBottom) 436 } 437 Viewnull438 private fun View.setBackgroundPaddingForNormalIcon() { 439 val sidePadding = 440 context.resources.getDimensionPixelSize( 441 R.dimen.ongoing_activity_chip_side_padding_legacy 442 ) 443 setPaddingRelative(sidePadding, paddingTop, sidePadding, paddingBottom) 444 } 445 setBackgroundPaddingForNoIconnull446 private fun View.setBackgroundPaddingForNoIcon() { 447 // The padding for the normal icon is also appropriate for no icon. 448 setBackgroundPaddingForNormalIcon() 449 } 450 setChipAccessibilitynull451 private fun setChipAccessibility( 452 chipModel: OngoingActivityChipModel.Active, 453 chipView: View, 454 chipBackgroundView: View, 455 ) { 456 when (chipModel) { 457 is OngoingActivityChipModel.Active.Countdown -> { 458 // Set as assertive so talkback will announce the countdown 459 chipView.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE 460 } 461 is OngoingActivityChipModel.Active.Timer, 462 is OngoingActivityChipModel.Active.Text, 463 is OngoingActivityChipModel.Active.ShortTimeDelta, 464 is OngoingActivityChipModel.Active.IconOnly -> { 465 chipView.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_NONE 466 } 467 } 468 // Clickable chips need to be a minimum size for accessibility purposes, but let 469 // non-clickable chips be smaller. 470 val minimumWidth = 471 if (chipModel.onClickListenerLegacy != null) { 472 chipBackgroundView.context.resources.getDimensionPixelSize( 473 R.dimen.min_clickable_item_size 474 ) 475 } else { 476 0 477 } 478 // The background view needs the minimum width so it only fills the area required (e.g. the 479 // 3-2-1 screen record countdown chip isn't tappable so it should have a small-width 480 // background). 481 chipBackgroundView.minimumWidth = minimumWidth 482 // The root view needs the minimum width so the second chip can hide if there isn't enough 483 // room for the chip -- see [SecondaryOngoingActivityChip]. 484 chipView.minimumWidth = minimumWidth 485 } 486 setBackgroundColorsnull487 private fun GradientDrawable.setBackgroundColors(colors: ColorsModel, context: Context) { 488 this.color = colors.background(context) 489 val outline = colors.outline(context) 490 if (outline != null) { 491 this.setStroke( 492 context.resources.getDimensionPixelSize( 493 R.dimen.ongoing_activity_chip_outline_width 494 ), 495 outline, 496 ) 497 } else { 498 this.setStroke(0, /* color= */ 0) 499 } 500 } 501 502 @IdRes private val CUSTOM_ICON_VIEW_ID = R.id.ongoing_activity_chip_custom_icon 503 } 504