1 /* 2 * Copyright (C) 2022 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.sender 18 19 import android.app.StatusBarManager 20 import android.content.Context 21 import android.media.MediaRoute2Info 22 import android.view.View 23 import com.android.internal.logging.InstanceId 24 import com.android.internal.logging.UiEventLogger 25 import com.android.internal.statusbar.IUndoMediaTransferCallback 26 import com.android.systemui.CoreStartable 27 import com.android.systemui.Dumpable 28 import com.android.systemui.R 29 import com.android.systemui.common.shared.model.Text 30 import com.android.systemui.dagger.SysUISingleton 31 import com.android.systemui.dump.DumpManager 32 import com.android.systemui.media.taptotransfer.MediaTttFlags 33 import com.android.systemui.media.taptotransfer.common.MediaTttUtils 34 import com.android.systemui.statusbar.CommandQueue 35 import com.android.systemui.temporarydisplay.TemporaryViewDisplayController 36 import com.android.systemui.temporarydisplay.ViewPriority 37 import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator 38 import com.android.systemui.temporarydisplay.chipbar.ChipbarEndItem 39 import com.android.systemui.temporarydisplay.chipbar.ChipbarInfo 40 import java.io.PrintWriter 41 import javax.inject.Inject 42 43 /** 44 * A coordinator for showing/hiding the Media Tap-To-Transfer UI on the **sending** device. This UI 45 * is shown when a user is transferring media to/from this device and a receiver device. 46 */ 47 @SysUISingleton 48 class MediaTttSenderCoordinator 49 @Inject 50 constructor( 51 private val chipbarCoordinator: ChipbarCoordinator, 52 private val commandQueue: CommandQueue, 53 private val context: Context, 54 private val dumpManager: DumpManager, 55 private val logger: MediaTttSenderLogger, 56 private val mediaTttFlags: MediaTttFlags, 57 private val uiEventLogger: MediaTttSenderUiEventLogger, 58 ) : CoreStartable, Dumpable { 59 60 // Since the media transfer display is similar to a heads-up notification, use the same timeout. 61 private val defaultTimeout = context.resources.getInteger(R.integer.heads_up_notification_decay) 62 63 // A map to store instance id and current chip state per id. 64 private var stateMap: MutableMap<String, Pair<InstanceId, ChipStateSender>> = mutableMapOf() 65 66 private val commandQueueCallbacks = 67 object : CommandQueue.Callbacks { 68 override fun updateMediaTapToTransferSenderDisplay( 69 @StatusBarManager.MediaTransferSenderState displayState: Int, 70 routeInfo: MediaRoute2Info, 71 undoCallback: IUndoMediaTransferCallback? 72 ) { 73 this@MediaTttSenderCoordinator.updateMediaTapToTransferSenderDisplay( 74 displayState, 75 routeInfo, 76 undoCallback 77 ) 78 } 79 } 80 81 override fun start() { 82 if (mediaTttFlags.isMediaTttEnabled()) { 83 commandQueue.addCallback(commandQueueCallbacks) 84 dumpManager.registerNormalDumpable(this) 85 } 86 } 87 88 private fun updateMediaTapToTransferSenderDisplay( 89 @StatusBarManager.MediaTransferSenderState displayState: Int, 90 routeInfo: MediaRoute2Info, 91 undoCallback: IUndoMediaTransferCallback? 92 ) { 93 val chipState: ChipStateSender? = ChipStateSender.getSenderStateFromId(displayState) 94 val stateName = chipState?.name ?: "Invalid" 95 logger.logStateChange(stateName, routeInfo.id, routeInfo.clientPackageName) 96 97 if (chipState == null) { 98 logger.logStateChangeError(displayState) 99 return 100 } 101 102 val currentStateForId: ChipStateSender? = stateMap[routeInfo.id]?.second 103 val instanceId: InstanceId = 104 stateMap[routeInfo.id]?.first 105 ?: chipbarCoordinator.tempViewUiEventLogger.getNewInstanceId() 106 if (!ChipStateSender.isValidStateTransition(currentStateForId, chipState)) { 107 // ChipStateSender.FAR_FROM_RECEIVER is the default state when there is no state. 108 logger.logInvalidStateTransitionError( 109 currentState = currentStateForId?.name ?: ChipStateSender.FAR_FROM_RECEIVER.name, 110 chipState.name 111 ) 112 return 113 } 114 uiEventLogger.logSenderStateChange(chipState, instanceId) 115 116 if (chipState == ChipStateSender.FAR_FROM_RECEIVER) { 117 // Return early if we're not displaying a chip for this ID anyway 118 if (currentStateForId == null) return 119 120 val removalReason = ChipStateSender.FAR_FROM_RECEIVER.name 121 if ( 122 currentStateForId.transferStatus == TransferStatus.IN_PROGRESS || 123 currentStateForId.transferStatus == TransferStatus.SUCCEEDED 124 ) { 125 // Don't remove the chip if we're in progress or succeeded, since the user should 126 // still be able to see the status of the transfer. 127 logger.logRemovalBypass( 128 removalReason, 129 bypassReason = "transferStatus=${currentStateForId.transferStatus.name}" 130 ) 131 return 132 } 133 134 // No need to store the state since it is the default state 135 removeIdFromStore(routeInfo.id, reason = removalReason) 136 chipbarCoordinator.removeView(routeInfo.id, removalReason) 137 } else { 138 stateMap[routeInfo.id] = Pair(instanceId, chipState) 139 logger.logStateMap(stateMap) 140 chipbarCoordinator.registerListener(displayListener) 141 chipbarCoordinator.displayView( 142 createChipbarInfo( 143 chipState, 144 routeInfo, 145 undoCallback, 146 context, 147 logger, 148 instanceId, 149 ) 150 ) 151 } 152 } 153 154 /** 155 * Creates an instance of [ChipbarInfo] that can be sent to [ChipbarCoordinator] for display. 156 */ 157 private fun createChipbarInfo( 158 chipStateSender: ChipStateSender, 159 routeInfo: MediaRoute2Info, 160 undoCallback: IUndoMediaTransferCallback?, 161 context: Context, 162 logger: MediaTttSenderLogger, 163 instanceId: InstanceId, 164 ): ChipbarInfo { 165 val packageName = checkNotNull(routeInfo.clientPackageName) 166 val otherDeviceName = 167 if (routeInfo.name.isBlank()) { 168 context.getString(R.string.media_ttt_default_device_type) 169 } else { 170 routeInfo.name.toString() 171 } 172 val icon = 173 MediaTttUtils.getIconInfoFromPackageName(context, packageName, isReceiver = false) { 174 logger.logPackageNotFound(packageName) 175 } 176 177 val timeout = 178 when (chipStateSender.timeoutLength) { 179 TimeoutLength.DEFAULT -> defaultTimeout 180 TimeoutLength.LONG -> 2 * defaultTimeout 181 } 182 183 return ChipbarInfo( 184 // Display the app's icon as the start icon 185 startIcon = icon.toTintedIcon(), 186 text = chipStateSender.getChipTextString(context, otherDeviceName), 187 endItem = 188 when (chipStateSender.endItem) { 189 null -> null 190 is SenderEndItem.Loading -> ChipbarEndItem.Loading 191 is SenderEndItem.Error -> ChipbarEndItem.Error 192 is SenderEndItem.UndoButton -> { 193 if (undoCallback != null) { 194 getUndoButton( 195 undoCallback, 196 chipStateSender.endItem.uiEventOnClick, 197 chipStateSender.endItem.newState, 198 routeInfo, 199 instanceId, 200 ) 201 } else { 202 null 203 } 204 } 205 }, 206 vibrationEffect = chipStateSender.transferStatus.vibrationEffect, 207 allowSwipeToDismiss = true, 208 windowTitle = MediaTttUtils.WINDOW_TITLE_SENDER, 209 wakeReason = MediaTttUtils.WAKE_REASON_SENDER, 210 timeoutMs = timeout, 211 id = routeInfo.id, 212 priority = ViewPriority.NORMAL, 213 instanceId = instanceId, 214 ) 215 } 216 217 /** 218 * Returns an undo button for the chip. 219 * 220 * When the button is clicked: [undoCallback] will be triggered, [uiEvent] will be logged, and 221 * this coordinator will transition to [newState]. 222 */ 223 private fun getUndoButton( 224 undoCallback: IUndoMediaTransferCallback, 225 uiEvent: UiEventLogger.UiEventEnum, 226 @StatusBarManager.MediaTransferSenderState newState: Int, 227 routeInfo: MediaRoute2Info, 228 instanceId: InstanceId, 229 ): ChipbarEndItem.Button { 230 val onClickListener = 231 View.OnClickListener { 232 uiEventLogger.logUndoClicked(uiEvent, instanceId) 233 undoCallback.onUndoTriggered() 234 235 // The external service should eventually send us a new TransferTriggered state, but 236 // but that may take too long to go through the binder and the user may be confused 237 // as to why the UI hasn't changed yet. So, we immediately change the UI here. 238 updateMediaTapToTransferSenderDisplay( 239 newState, 240 routeInfo, 241 // Since we're force-updating the UI, we don't have any [undoCallback] from the 242 // external service (and TransferTriggered states don't have undo callbacks 243 // anyway). 244 undoCallback = null, 245 ) 246 } 247 248 return ChipbarEndItem.Button( 249 Text.Resource(R.string.media_transfer_undo), 250 onClickListener, 251 ) 252 } 253 254 private val displayListener = 255 TemporaryViewDisplayController.Listener { id, reason -> removeIdFromStore(id, reason) } 256 257 private fun removeIdFromStore(id: String, reason: String) { 258 logger.logStateMapRemoval(id, reason) 259 stateMap.remove(id) 260 logger.logStateMap(stateMap) 261 if (stateMap.isEmpty()) { 262 chipbarCoordinator.unregisterListener(displayListener) 263 } 264 } 265 266 override fun dump(pw: PrintWriter, args: Array<out String>) { 267 pw.println("Current sender states:") 268 pw.println(stateMap.toString()) 269 } 270 } 271