1 /* <lambda>null2 * Copyright (C) 2021 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.media.taptotransfer.receiver 18 19 import android.animation.TimeInterpolator 20 import android.annotation.SuppressLint 21 import android.animation.ValueAnimator 22 import android.app.StatusBarManager 23 import android.content.Context 24 import android.graphics.Rect 25 import android.graphics.drawable.Drawable 26 import android.graphics.drawable.Icon 27 import android.media.MediaRoute2Info 28 import android.os.Handler 29 import android.os.PowerManager 30 import android.view.Gravity 31 import android.view.View 32 import android.view.ViewGroup 33 import android.view.WindowManager 34 import android.view.accessibility.AccessibilityManager 35 import android.view.View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE 36 import android.view.View.ACCESSIBILITY_LIVE_REGION_NONE 37 import com.android.internal.widget.CachingIconView 38 import com.android.systemui.res.R 39 import com.android.app.animation.Interpolators 40 import com.android.internal.logging.InstanceId 41 import com.android.systemui.common.shared.model.ContentDescription 42 import com.android.systemui.common.ui.binder.TintedIconViewBinder 43 import com.android.systemui.dagger.SysUISingleton 44 import com.android.systemui.dagger.qualifiers.Main 45 import com.android.systemui.dump.DumpManager 46 import com.android.systemui.media.taptotransfer.MediaTttFlags 47 import com.android.systemui.media.taptotransfer.common.MediaTttIcon 48 import com.android.systemui.media.taptotransfer.common.MediaTttUtils 49 import com.android.systemui.statusbar.CommandQueue 50 import com.android.systemui.statusbar.policy.ConfigurationController 51 import com.android.systemui.temporarydisplay.TemporaryViewDisplayController 52 import com.android.systemui.temporarydisplay.TemporaryViewInfo 53 import com.android.systemui.temporarydisplay.TemporaryViewUiEventLogger 54 import com.android.systemui.temporarydisplay.ViewPriority 55 import com.android.systemui.util.animation.AnimationUtil.Companion.frames 56 import com.android.systemui.util.concurrency.DelayableExecutor 57 import com.android.systemui.util.time.SystemClock 58 import com.android.systemui.util.view.ViewUtil 59 import com.android.systemui.util.wakelock.WakeLock 60 import javax.inject.Inject 61 62 /** 63 * A controller to display and hide the Media Tap-To-Transfer chip on the **receiving** device. 64 * 65 * This chip is shown when a user is transferring media to/from a sending device and this device. 66 * 67 * TODO(b/245610654): Re-name this to be MediaTttReceiverCoordinator. 68 */ 69 @SysUISingleton 70 open class MediaTttChipControllerReceiver @Inject constructor( 71 private val commandQueue: CommandQueue, 72 context: Context, 73 logger: MediaTttReceiverLogger, 74 windowManager: WindowManager, 75 @Main mainExecutor: DelayableExecutor, 76 accessibilityManager: AccessibilityManager, 77 configurationController: ConfigurationController, 78 dumpManager: DumpManager, 79 powerManager: PowerManager, 80 @Main private val mainHandler: Handler, 81 private val mediaTttFlags: MediaTttFlags, 82 private val uiEventLogger: MediaTttReceiverUiEventLogger, 83 private val viewUtil: ViewUtil, 84 wakeLockBuilder: WakeLock.Builder, 85 systemClock: SystemClock, 86 private val rippleController: MediaTttReceiverRippleController, 87 private val temporaryViewUiEventLogger: TemporaryViewUiEventLogger, 88 ) : TemporaryViewDisplayController<ChipReceiverInfo, MediaTttReceiverLogger>( 89 context, 90 logger, 91 windowManager, 92 mainExecutor, 93 accessibilityManager, 94 configurationController, 95 dumpManager, 96 powerManager, 97 R.layout.media_ttt_chip_receiver, 98 wakeLockBuilder, 99 systemClock, 100 temporaryViewUiEventLogger, 101 ) { 102 @SuppressLint("WrongConstant") // We're allowed to use LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 103 override val windowLayoutParams = commonWindowLayoutParams.apply { 104 gravity = Gravity.BOTTOM.or(Gravity.CENTER_HORIZONTAL) 105 // Params below are needed for the ripple to work correctly 106 width = WindowManager.LayoutParams.MATCH_PARENT 107 height = WindowManager.LayoutParams.MATCH_PARENT 108 layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 109 fitInsetsTypes = 0 // Ignore insets from all system bars 110 } 111 112 // Value animator that controls the bouncing animation of views. 113 private val bounceAnimator = ValueAnimator.ofFloat(0f, 1f).apply { 114 repeatCount = ValueAnimator.INFINITE 115 repeatMode = ValueAnimator.REVERSE 116 duration = ICON_BOUNCE_ANIM_DURATION 117 } 118 119 private val commandQueueCallbacks = object : CommandQueue.Callbacks { 120 override fun updateMediaTapToTransferReceiverDisplay( 121 @StatusBarManager.MediaTransferReceiverState displayState: Int, 122 routeInfo: MediaRoute2Info, 123 appIcon: Icon?, 124 appName: CharSequence? 125 ) { 126 this@MediaTttChipControllerReceiver.updateMediaTapToTransferReceiverDisplay( 127 displayState, routeInfo, appIcon, appName 128 ) 129 } 130 } 131 132 // A map to store instance id per route info id. 133 private var instanceMap: MutableMap<String, InstanceId> = mutableMapOf() 134 135 private val displayListener = Listener { id, _ -> instanceMap.remove(id) } 136 137 private fun updateMediaTapToTransferReceiverDisplay( 138 @StatusBarManager.MediaTransferReceiverState displayState: Int, 139 routeInfo: MediaRoute2Info, 140 appIcon: Icon?, 141 appName: CharSequence? 142 ) { 143 val chipState: ChipStateReceiver? = ChipStateReceiver.getReceiverStateFromId(displayState) 144 val stateName = chipState?.name ?: "Invalid" 145 logger.logStateChange(stateName, routeInfo.id, routeInfo.clientPackageName) 146 147 if (chipState == null) { 148 logger.logStateChangeError(displayState) 149 return 150 } 151 152 val instanceId: InstanceId = instanceMap[routeInfo.id] 153 ?: temporaryViewUiEventLogger.getNewInstanceId() 154 uiEventLogger.logReceiverStateChange(chipState, instanceId) 155 156 if (chipState != ChipStateReceiver.CLOSE_TO_SENDER) { 157 removeView(routeInfo.id, removalReason = chipState.name) 158 return 159 } 160 161 // Save instance id to use for logging view events. 162 instanceMap[routeInfo.id] = instanceId 163 if (appIcon == null) { 164 displayView( 165 ChipReceiverInfo( 166 routeInfo, 167 appIconDrawableOverride = null, 168 appName, 169 id = routeInfo.id, 170 instanceId = instanceId, 171 ) 172 ) 173 return 174 } 175 176 appIcon.loadDrawableAsync( 177 context, 178 Icon.OnDrawableLoadedListener { drawable -> 179 displayView( 180 ChipReceiverInfo( 181 routeInfo, 182 drawable, 183 appName, 184 id = routeInfo.id, 185 instanceId = instanceId, 186 ) 187 ) 188 }, 189 // Notify the listener on the main handler since the listener will update 190 // the UI. 191 mainHandler 192 ) 193 } 194 195 override fun start() { 196 super.start() 197 if (mediaTttFlags.isMediaTttEnabled()) { 198 commandQueue.addCallback(commandQueueCallbacks) 199 } 200 registerListener(displayListener) 201 } 202 203 override fun updateView(newInfo: ChipReceiverInfo, currentView: ViewGroup) { 204 val packageName: String? = newInfo.routeInfo.clientPackageName 205 var iconInfo = MediaTttUtils.getIconInfoFromPackageName( 206 context, 207 packageName, 208 isReceiver = true, 209 ) { 210 packageName?.let { logger.logPackageNotFound(it) } 211 } 212 213 if (newInfo.appNameOverride != null) { 214 iconInfo = iconInfo.copy( 215 contentDescription = ContentDescription.Loaded(newInfo.appNameOverride.toString()) 216 ) 217 } 218 219 if (newInfo.appIconDrawableOverride != null) { 220 iconInfo = iconInfo.copy( 221 icon = MediaTttIcon.Loaded(newInfo.appIconDrawableOverride), 222 isAppIcon = true, 223 ) 224 } 225 226 val iconPadding = 227 if (iconInfo.isAppIcon) { 228 0 229 } else { 230 context.resources.getDimensionPixelSize(R.dimen.media_ttt_generic_icon_padding) 231 } 232 233 val iconView = currentView.getAppIconView() 234 iconView.setPadding(iconPadding, iconPadding, iconPadding, iconPadding) 235 TintedIconViewBinder.bind(iconInfo.toTintedIcon(), iconView) 236 237 val iconContainerView = currentView.getIconContainerView() 238 iconContainerView.accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_ASSERTIVE 239 } 240 241 override fun animateViewIn(view: ViewGroup) { 242 val iconContainerView = view.getIconContainerView() 243 val iconRippleView: ReceiverChipRippleView = view.requireViewById(R.id.icon_glow_ripple) 244 val rippleView: ReceiverChipRippleView = view.requireViewById(R.id.ripple) 245 val translationYBy = getTranslationAmount() 246 // Expand ripple before translating icon container to make sure both views have same bounds. 247 rippleController.expandToInProgressState(rippleView, iconRippleView) 248 // Make the icon container view starts animation from bottom of the screen. 249 iconContainerView.translationY = rippleController.getReceiverIconSize().toFloat() 250 animateViewTranslationAndFade( 251 iconContainerView, 252 translationYBy = -1 * translationYBy, 253 alphaEndValue = 1f, 254 Interpolators.EMPHASIZED_DECELERATE, 255 ) { 256 animateBouncingView(iconContainerView, translationYBy * BOUNCE_TRANSLATION_RATIO) 257 } 258 } 259 260 override fun animateViewOut(view: ViewGroup, removalReason: String?, onAnimationEnd: Runnable) { 261 val iconContainerView = view.getIconContainerView() 262 val rippleView: ReceiverChipRippleView = view.requireViewById(R.id.ripple) 263 val translationYBy = getTranslationAmount() 264 265 // Remove update listeners from bounce animator to prevent any conflict with 266 // translation animation. 267 bounceAnimator.removeAllUpdateListeners() 268 bounceAnimator.cancel() 269 if (removalReason == ChipStateReceiver.TRANSFER_TO_RECEIVER_SUCCEEDED.name) { 270 rippleController.expandToSuccessState(rippleView, onAnimationEnd) 271 animateViewTranslationAndFade( 272 iconContainerView, 273 -1 * translationYBy, 274 0f, 275 translationDuration = ICON_TRANSLATION_SUCCEEDED_DURATION, 276 alphaDuration = ICON_TRANSLATION_SUCCEEDED_DURATION, 277 ) 278 } else { 279 rippleController.collapseRipple(rippleView, onAnimationEnd) 280 animateViewTranslationAndFade(iconContainerView, translationYBy, 0f) 281 } 282 } 283 284 override fun getTouchableRegion(view: View, outRect: Rect) { 285 // Even though the app icon view isn't touchable, users might think it is. So, use it as the 286 // touchable region to ensure that touches don't get passed to the window below. 287 viewUtil.setRectToViewWindowLocation(view.getAppIconView(), outRect) 288 } 289 290 /** Animation of view translation and fading. */ 291 private fun animateViewTranslationAndFade( 292 view: ViewGroup, 293 translationYBy: Float, 294 alphaEndValue: Float, 295 interpolator: TimeInterpolator? = null, 296 translationDuration: Long = ICON_TRANSLATION_ANIM_DURATION, 297 alphaDuration: Long = ICON_ALPHA_ANIM_DURATION, 298 onAnimationEnd: Runnable? = null, 299 ) { 300 view.animate() 301 .translationYBy(translationYBy) 302 .setInterpolator(interpolator) 303 .setDuration(translationDuration) 304 .withEndAction { onAnimationEnd?.run() } 305 .start() 306 view.animate() 307 .alpha(alphaEndValue) 308 .setDuration(alphaDuration) 309 .start() 310 } 311 312 /** Returns the amount that the chip will be translated by in its intro animation. */ 313 private fun getTranslationAmount(): Float { 314 return rippleController.getReceiverIconSize() * 2f 315 } 316 317 private fun View.getAppIconView(): CachingIconView { 318 return this.requireViewById(R.id.app_icon) 319 } 320 321 private fun View.getIconContainerView(): ViewGroup { 322 return this.requireViewById(R.id.icon_container_view) 323 } 324 325 private fun animateBouncingView(iconContainerView: ViewGroup, translationYBy: Float) { 326 if (bounceAnimator.isStarted) { 327 return 328 } 329 330 addViewToBounceAnimation(iconContainerView, translationYBy) 331 332 // In order not to announce description every time the view animate. 333 iconContainerView.accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_NONE 334 bounceAnimator.start() 335 } 336 337 private fun addViewToBounceAnimation(view: View, translationYBy: Float) { 338 val prevTranslationY = view.translationY 339 bounceAnimator.addUpdateListener { updateListener -> 340 val progress = updateListener.animatedValue as Float 341 view.translationY = prevTranslationY + translationYBy * progress 342 } 343 } 344 345 companion object { 346 private const val ICON_TRANSLATION_ANIM_DURATION = 500L 347 private const val ICON_BOUNCE_ANIM_DURATION = 750L 348 private const val ICON_TRANSLATION_SUCCEEDED_DURATION = 167L 349 private const val BOUNCE_TRANSLATION_RATIO = 0.15f 350 private val ICON_ALPHA_ANIM_DURATION = 5.frames 351 } 352 } 353 354 data class ChipReceiverInfo( 355 val routeInfo: MediaRoute2Info, 356 val appIconDrawableOverride: Drawable?, 357 val appNameOverride: CharSequence?, 358 override val windowTitle: String = MediaTttUtils.WINDOW_TITLE_RECEIVER, 359 override val wakeReason: String = MediaTttUtils.WAKE_REASON_RECEIVER, 360 override val id: String, 361 override val priority: ViewPriority = ViewPriority.NORMAL, 362 override val instanceId: InstanceId, 363 ) : TemporaryViewInfo() 364