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