[Media TTT] Add an undo callback to the interface for when a transfer

has succeeded. The callback will be invoked when the user presses the
undo button.

Bug: 203800643
Bug: 203800347
Test: verify `adb shell cmd statusbar media-ttt-chip-add-sender Device
TransferToReceiverSucceeded` shows a chip with an Undo button. Verify
tapping the undo button switches the chip to the loading state again.
Test: media.taptotransfer tests

Change-Id: I912caa4408badffaa7a8e19f04e89bafd5d9a8ad
This commit is contained in:
Caitlin Cassidy
2022-01-14 20:06:51 +00:00
parent a6d18d89f9
commit 5852540a88
9 changed files with 169 additions and 69 deletions

View File

@@ -18,16 +18,17 @@ package com.android.systemui.shared.mediattt;
import android.media.MediaRoute2Info;
import com.android.systemui.shared.mediattt.DeviceInfo;
import com.android.systemui.shared.mediattt.IUndoTransferCallback;
/**
* A callback interface that can be invoked to trigger media transfer events on System UI.
* An interface that can be invoked to trigger media transfer events on System UI.
*
* This interface is for the *sender* device, which is the device currently playing media. This
* sender device can transfer the media to a different device, called the receiver.
*
* System UI will implement this interface and other services will invoke it.
*/
interface IDeviceSenderCallback {
interface IDeviceSenderService {
/**
* Invoke to notify System UI that this device (the sender) is close to a receiver device, so
* the user can potentially *start* a cast to the receiver device if the user moves their device
@@ -92,9 +93,13 @@ interface IDeviceSenderCallback {
* - This callback is for *starting* a cast. It should be used when this device had previously
* been playing media locally and the media has successfully been transferred to the
* receiver device instead.
*
* @param undoCallback will be invoked if the user chooses to undo this transfer.
*/
oneway void transferToReceiverSucceeded(
in MediaRoute2Info mediaInfo, in DeviceInfo otherDeviceInfo);
in MediaRoute2Info mediaInfo,
in DeviceInfo otherDeviceInfo,
in IUndoTransferCallback undoCallback);
/**
* Invoke to notify System UI that the attempted transfer has failed.

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.shared.mediattt;
/**
* An interface that will be invoked by System UI if the user choose to undo a transfer.
*
* Other services will implement this interface and System UI will invoke it.
*/
interface IUndoTransferCallback {
/**
* Invoked by SystemUI when the user requests to undo the media transfer that just occurred.
*
* Implementors of this method are repsonsible for actually undoing the transfer.
*/
oneway void onUndoTriggered();
}

View File

@@ -39,7 +39,8 @@ import com.android.systemui.media.taptotransfer.sender.TransferToReceiverTrigger
import com.android.systemui.media.taptotransfer.sender.TransferToThisDeviceTriggered
import com.android.systemui.media.taptotransfer.sender.TransferToReceiverSucceeded
import com.android.systemui.shared.mediattt.DeviceInfo
import com.android.systemui.shared.mediattt.IDeviceSenderCallback
import com.android.systemui.shared.mediattt.IDeviceSenderService
import com.android.systemui.shared.mediattt.IUndoTransferCallback
import com.android.systemui.statusbar.commandline.Command
import com.android.systemui.statusbar.commandline.CommandRegistry
import java.io.PrintWriter
@@ -56,7 +57,7 @@ class MediaTttCommandLineHelper @Inject constructor(
private val mediaTttChipControllerSender: MediaTttChipControllerSender,
private val mediaTttChipControllerReceiver: MediaTttChipControllerReceiver,
) {
private var senderCallback: IDeviceSenderCallback? = null
private var senderService: IDeviceSenderService? = null
private val senderServiceConnection = SenderServiceConnection()
private val appIconDrawable =
@@ -85,33 +86,45 @@ class MediaTttCommandLineHelper @Inject constructor(
when (args[1]) {
MOVE_CLOSER_TO_START_CAST_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.closeToReceiverToStartCast(mediaInfo, otherDeviceInfo)
runOnService { senderService ->
senderService.closeToReceiverToStartCast(mediaInfo, otherDeviceInfo)
}
}
MOVE_CLOSER_TO_END_CAST_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.closeToReceiverToEndCast(mediaInfo, otherDeviceInfo)
runOnService { senderService ->
senderService.closeToReceiverToEndCast(mediaInfo, otherDeviceInfo)
}
}
TRANSFER_TO_RECEIVER_TRIGGERED_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.transferToReceiverTriggered(mediaInfo, otherDeviceInfo)
runOnService { senderService ->
senderService.transferToReceiverTriggered(mediaInfo, otherDeviceInfo)
}
}
TRANSFER_TO_THIS_DEVICE_TRIGGERED_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.transferToThisDeviceTriggered(mediaInfo, otherDeviceInfo)
runOnService { senderService ->
senderService.transferToThisDeviceTriggered(mediaInfo, otherDeviceInfo)
}
}
TRANSFER_TO_RECEIVER_SUCCEEDED_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.transferToReceiverSucceeded(mediaInfo, otherDeviceInfo)
val undoCallback = object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {
Log.i(TAG, "Undo callback triggered")
// The external services that implement this callback would kick off a
// transfer back to this device, so mimic that here.
runOnService { senderService ->
senderService
.transferToThisDeviceTriggered(mediaInfo, otherDeviceInfo)
}
}
}
runOnService { senderService ->
senderService
.transferToReceiverSucceeded(mediaInfo, otherDeviceInfo, undoCallback)
}
}
TRANSFER_FAILED_COMMAND_NAME -> {
runOnService { senderCallback ->
senderCallback.transferFailed(mediaInfo, otherDeviceInfo)
runOnService { senderService ->
senderService.transferFailed(mediaInfo, otherDeviceInfo)
}
}
else -> {
@@ -133,16 +146,16 @@ class MediaTttCommandLineHelper @Inject constructor(
)
}
private fun runOnService(command: SenderCallbackCommand) {
val currentServiceCallback = senderCallback
if (currentServiceCallback != null) {
command.run(currentServiceCallback)
private fun runOnService(command: SenderServiceCommand) {
val currentService = senderService
if (currentService != null) {
command.run(currentService)
} else {
bindService(command)
}
}
private fun bindService(command: SenderCallbackCommand) {
private fun bindService(command: SenderServiceCommand) {
senderServiceConnection.pendingCommand = command
val binding = context.bindService(
Intent(context, MediaTttSenderService::class.java),
@@ -157,7 +170,7 @@ class MediaTttCommandLineHelper @Inject constructor(
inner class RemoveChipCommandSender : Command {
override fun execute(pw: PrintWriter, args: List<String>) {
mediaTttChipControllerSender.removeChip()
if (senderCallback != null) {
if (senderService != null) {
context.unbindService(senderServiceConnection)
}
}
@@ -188,27 +201,27 @@ class MediaTttCommandLineHelper @Inject constructor(
}
}
/** A service connection for [IDeviceSenderCallback]. */
/** A service connection for [IDeviceSenderService]. */
private inner class SenderServiceConnection : ServiceConnection {
// A command that should be run when the service gets connected.
var pendingCommand: SenderCallbackCommand? = null
var pendingCommand: SenderServiceCommand? = null
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val newCallback = IDeviceSenderCallback.Stub.asInterface(service)
senderCallback = newCallback
val newCallback = IDeviceSenderService.Stub.asInterface(service)
senderService = newCallback
pendingCommand?.run(newCallback)
pendingCommand = null
}
override fun onServiceDisconnected(className: ComponentName) {
senderCallback = null
senderService = null
}
}
/** An interface defining a command that should be run on the sender callback. */
private fun interface SenderCallbackCommand {
/** Runs the command on the provided [senderCallback]. */
fun run(senderCallback: IDeviceSenderCallback)
/** An interface defining a command that should be run on the sender service. */
private fun interface SenderServiceCommand {
/** Runs the command on the provided [senderService]. */
fun run(senderService: IDeviceSenderService)
}
}

View File

@@ -20,6 +20,7 @@ import android.content.Context
import android.graphics.drawable.Drawable
import com.android.systemui.R
import com.android.systemui.media.taptotransfer.common.MediaTttChipState
import com.android.systemui.shared.mediattt.IUndoTransferCallback
/**
* A class that stores all the information necessary to display the media tap-to-transfer chip on
@@ -114,14 +115,14 @@ class TransferToThisDeviceTriggered(
* A state representing that a transfer to the receiver device has been successfully completed.
*
* @property otherDeviceName the name of the other device involved in the transfer.
* @property undoRunnable if present, the runnable that should be run to undo the transfer. We will
* show an Undo button on the chip if this runnable is present.
* @property undoCallback if present, the callback that should be called when the user clicks the
* undo button. The undo button will only be shown if this is non-null.
*/
class TransferToReceiverSucceeded(
appIconDrawable: Drawable,
appIconContentDescription: String,
private val otherDeviceName: String,
val undoRunnable: Runnable? = null
val undoCallback: IUndoTransferCallback? = null
) : ChipStateSender(appIconDrawable, appIconContentDescription) {
override fun getChipTextString(context: Context): String {
return context.getString(R.string.media_transfer_playing_different_device, otherDeviceName)

View File

@@ -54,8 +54,20 @@ class MediaTttChipControllerSender @Inject constructor(
// Undo
val undoClickListener: View.OnClickListener? =
if (chipState is TransferToReceiverSucceeded && chipState.undoRunnable != null)
View.OnClickListener { chipState.undoRunnable.run() }
if (chipState is TransferToReceiverSucceeded && chipState.undoCallback != null)
View.OnClickListener {
chipState.undoCallback.onUndoTriggered()
// The external service should eventually send us a
// TransferToThisDeviceTriggered state, but that may take too long to go through
// the binder and the user may be confused as to why the UI hasn't changed yet.
// So, we immediately change the UI here.
displayChip(
TransferToThisDeviceTriggered(
chipState.appIconDrawable,
chipState.appIconContentDescription
)
)
}
else
null
val undoView = currentChipView.requireViewById<View>(R.id.undo)

View File

@@ -25,7 +25,8 @@ import android.media.MediaRoute2Info
import android.os.IBinder
import com.android.systemui.R
import com.android.systemui.shared.mediattt.DeviceInfo
import com.android.systemui.shared.mediattt.IDeviceSenderCallback
import com.android.systemui.shared.mediattt.IUndoTransferCallback
import com.android.systemui.shared.mediattt.IDeviceSenderService
import javax.inject.Inject
/**
@@ -37,7 +38,7 @@ class MediaTttSenderService @Inject constructor(
) : Service() {
// TODO(b/203800643): Add logging when callbacks trigger.
private val binder: IBinder = object : IDeviceSenderCallback.Stub() {
private val binder: IBinder = object : IDeviceSenderService.Stub() {
override fun closeToReceiverToStartCast(
mediaInfo: MediaRoute2Info, otherDeviceInfo: DeviceInfo
) {
@@ -69,9 +70,13 @@ class MediaTttSenderService @Inject constructor(
}
override fun transferToReceiverSucceeded(
mediaInfo: MediaRoute2Info, otherDeviceInfo: DeviceInfo
mediaInfo: MediaRoute2Info,
otherDeviceInfo: DeviceInfo,
undoCallback: IUndoTransferCallback
) {
this@MediaTttSenderService.transferToReceiverSucceeded(mediaInfo, otherDeviceInfo)
this@MediaTttSenderService.transferToReceiverSucceeded(
mediaInfo, otherDeviceInfo, undoCallback
)
}
}
@@ -131,14 +136,13 @@ class MediaTttSenderService @Inject constructor(
}
private fun transferToReceiverSucceeded(
mediaInfo: MediaRoute2Info, otherDeviceInfo: DeviceInfo
mediaInfo: MediaRoute2Info, otherDeviceInfo: DeviceInfo, undoCallback: IUndoTransferCallback
) {
val chipState = TransferToReceiverSucceeded(
appIconDrawable = fakeAppIconDrawable,
appIconContentDescription = mediaInfo.name.toString(),
otherDeviceName = otherDeviceInfo.name
// TODO(b/203800643): Implement the undo API correctly so we can provide an undo
// callback here.
otherDeviceName = otherDeviceInfo.name,
undoCallback = undoCallback
)
controller.displayChip(chipState)
}

View File

@@ -21,9 +21,10 @@ import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.media.taptotransfer.receiver.ChipStateReceiver
import com.android.systemui.media.taptotransfer.receiver.MediaTttChipControllerReceiver
import com.android.systemui.media.taptotransfer.sender.*
import com.android.systemui.media.taptotransfer.sender.MediaTttChipControllerSender
import com.android.systemui.media.taptotransfer.sender.MediaTttSenderService
import com.android.systemui.shared.mediattt.DeviceInfo
import com.android.systemui.shared.mediattt.IDeviceSenderCallback
import com.android.systemui.shared.mediattt.IDeviceSenderService
import com.android.systemui.statusbar.commandline.Command
import com.android.systemui.statusbar.commandline.CommandRegistry
import com.android.systemui.util.mockito.any
@@ -55,7 +56,7 @@ class MediaTttCommandLineHelperTest : SysuiTestCase() {
@Mock
private lateinit var mediaTttChipControllerReceiver: MediaTttChipControllerReceiver
@Mock
private lateinit var mediaSenderService: IDeviceSenderCallback.Stub
private lateinit var mediaSenderService: IDeviceSenderService.Stub
private lateinit var mediaSenderServiceComponentName: ComponentName
@Before
@@ -160,7 +161,8 @@ class MediaTttCommandLineHelperTest : SysuiTestCase() {
assertThat(context.isBound(mediaSenderServiceComponentName)).isTrue()
val deviceInfoCaptor = argumentCaptor<DeviceInfo>()
verify(mediaSenderService).transferToReceiverSucceeded(any(), capture(deviceInfoCaptor))
verify(mediaSenderService)
.transferToReceiverSucceeded(any(), capture(deviceInfoCaptor), any())
assertThat(deviceInfoCaptor.value!!.name).isEqualTo(DEVICE_NAME)
}

View File

@@ -26,6 +26,7 @@ import android.widget.TextView
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.shared.mediattt.IUndoTransferCallback
import com.android.systemui.util.mockito.any
import com.google.common.truth.Truth.assertThat
import org.junit.Before
@@ -122,7 +123,7 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
@Test
fun transferToReceiverSucceeded_nullUndoRunnable_noUndo() {
controllerSender.displayChip(transferToReceiverSucceeded(undoRunnable = null))
controllerSender.displayChip(transferToReceiverSucceeded(undoCallback = null))
val chipView = getChipView()
assertThat(chipView.getUndoButton().visibility).isEqualTo(View.GONE)
@@ -130,7 +131,10 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
@Test
fun transferToReceiverSucceeded_withUndoRunnable_undoWithClick() {
controllerSender.displayChip(transferToReceiverSucceeded { })
val undoCallback = object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {}
}
controllerSender.displayChip(transferToReceiverSucceeded(undoCallback))
val chipView = getChipView()
assertThat(chipView.getUndoButton().visibility).isEqualTo(View.VISIBLE)
@@ -139,13 +143,30 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
@Test
fun transferToReceiverSucceeded_withUndoRunnable_undoButtonClickRunsRunnable() {
var runnableRun = false
val runnable = Runnable { runnableRun = true }
var undoCallbackCalled = false
val undoCallback = object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {
undoCallbackCalled = true
}
}
controllerSender.displayChip(transferToReceiverSucceeded(undoRunnable = runnable))
controllerSender.displayChip(transferToReceiverSucceeded(undoCallback))
getChipView().getUndoButton().performClick()
assertThat(runnableRun).isTrue()
assertThat(undoCallbackCalled).isTrue()
}
@Test
fun transferToReceiverSucceeded_undoButtonClick_switchesToTransferToThisDeviceTriggered() {
val undoCallback = object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {}
}
controllerSender.displayChip(transferToReceiverSucceeded(undoCallback))
getChipView().getUndoButton().performClick()
assertThat(getChipView().getChipText())
.isEqualTo(transferToThisDeviceTriggered().getChipTextString(context))
}
@Test
@@ -181,7 +202,13 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
@Test
fun changeFromTransferTriggeredToTransferSucceeded_undoButtonAppears() {
controllerSender.displayChip(transferToReceiverTriggered())
controllerSender.displayChip(transferToReceiverSucceeded { })
controllerSender.displayChip(
transferToReceiverSucceeded(
object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {}
}
)
)
assertThat(getChipView().getUndoButton().visibility).isEqualTo(View.VISIBLE)
}
@@ -237,9 +264,9 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
TransferToThisDeviceTriggered(appIconDrawable, APP_ICON_CONTENT_DESC)
/** Helper method providing default parameters to not clutter up the tests. */
private fun transferToReceiverSucceeded(undoRunnable: Runnable? = null) =
private fun transferToReceiverSucceeded(undoCallback: IUndoTransferCallback? = null) =
TransferToReceiverSucceeded(
appIconDrawable, APP_ICON_CONTENT_DESC, DEVICE_NAME, undoRunnable
appIconDrawable, APP_ICON_CONTENT_DESC, DEVICE_NAME, undoCallback
)
/** Helper method providing default parameters to not clutter up the tests. */

View File

@@ -4,7 +4,8 @@ import android.media.MediaRoute2Info
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.shared.mediattt.DeviceInfo
import com.android.systemui.shared.mediattt.IDeviceSenderCallback
import com.android.systemui.shared.mediattt.IDeviceSenderService
import com.android.systemui.shared.mediattt.IUndoTransferCallback
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
@@ -18,8 +19,7 @@ import org.mockito.MockitoAnnotations
@SmallTest
class MediaTttSenderServiceTest : SysuiTestCase() {
private lateinit var service: MediaTttSenderService
private lateinit var callback: IDeviceSenderCallback
private lateinit var service: IDeviceSenderService
@Mock
private lateinit var controller: MediaTttChipControllerSender
@@ -31,14 +31,14 @@ class MediaTttSenderServiceTest : SysuiTestCase() {
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
service = MediaTttSenderService(context, controller)
callback = IDeviceSenderCallback.Stub.asInterface(service.onBind(null))
val mediaTttSenderService = MediaTttSenderService(context, controller)
service = IDeviceSenderService.Stub.asInterface(mediaTttSenderService.onBind(null))
}
@Test
fun closeToReceiverToStartCast_controllerTriggeredWithCorrectState() {
val name = "Fake name"
callback.closeToReceiverToStartCast(mediaInfo, DeviceInfo(name))
service.closeToReceiverToStartCast(mediaInfo, DeviceInfo(name))
val chipStateCaptor = argumentCaptor<MoveCloserToStartCast>()
verify(controller).displayChip(capture(chipStateCaptor))
@@ -50,7 +50,7 @@ class MediaTttSenderServiceTest : SysuiTestCase() {
@Test
fun closeToReceiverToEndCast_controllerTriggeredWithCorrectState() {
val name = "Fake name"
callback.closeToReceiverToEndCast(mediaInfo, DeviceInfo(name))
service.closeToReceiverToEndCast(mediaInfo, DeviceInfo(name))
val chipStateCaptor = argumentCaptor<MoveCloserToEndCast>()
verify(controller).displayChip(capture(chipStateCaptor))
@@ -61,7 +61,7 @@ class MediaTttSenderServiceTest : SysuiTestCase() {
@Test
fun transferToThisDeviceTriggered_controllerTriggeredWithCorrectState() {
callback.transferToThisDeviceTriggered(mediaInfo, DeviceInfo("Fake name"))
service.transferToThisDeviceTriggered(mediaInfo, DeviceInfo("Fake name"))
verify(controller).displayChip(any<TransferToThisDeviceTriggered>())
}
@@ -69,7 +69,7 @@ class MediaTttSenderServiceTest : SysuiTestCase() {
@Test
fun transferToReceiverTriggered_controllerTriggeredWithCorrectState() {
val name = "Fake name"
callback.transferToReceiverTriggered(mediaInfo, DeviceInfo(name))
service.transferToReceiverTriggered(mediaInfo, DeviceInfo(name))
val chipStateCaptor = argumentCaptor<TransferToReceiverTriggered>()
verify(controller).displayChip(capture(chipStateCaptor))
@@ -81,18 +81,22 @@ class MediaTttSenderServiceTest : SysuiTestCase() {
@Test
fun transferToReceiverSucceeded_controllerTriggeredWithCorrectState() {
val name = "Fake name"
callback.transferToReceiverSucceeded(mediaInfo, DeviceInfo(name))
val undoCallback = object : IUndoTransferCallback.Stub() {
override fun onUndoTriggered() {}
}
service.transferToReceiverSucceeded(mediaInfo, DeviceInfo(name), undoCallback)
val chipStateCaptor = argumentCaptor<TransferToReceiverSucceeded>()
verify(controller).displayChip(capture(chipStateCaptor))
val chipState = chipStateCaptor.value!!
assertThat(chipState.getChipTextString(context)).contains(name)
assertThat(chipState.undoCallback).isEqualTo(undoCallback)
}
@Test
fun transferFailed_controllerTriggeredWithTransferFailedState() {
callback.transferFailed(mediaInfo, DeviceInfo("Fake name"))
service.transferFailed(mediaInfo, DeviceInfo("Fake name"))
verify(controller).displayChip(any<TransferFailed>())
}