blob: 508ffda43a8d9124daf49a0b72996075376fd427 [file] [log] [blame]
/*
* Copyright (C) 2023 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.google.gct.directaccess.ui
import com.android.adblib.utils.createChildScope
import com.android.sdklib.deviceprovisioner.DeviceState
import com.android.tools.adtui.TreeWalker
import com.android.tools.adtui.categorytable.CategoryTable
import com.android.tools.idea.concurrency.AndroidDispatchers
import com.android.tools.idea.deviceprovisioner.DeviceProvisionerService
import com.android.tools.idea.io.grpc.Status
import com.android.tools.idea.io.grpc.StatusRuntimeException
import com.google.gct.directaccess.DirectAccessPermissionStatus
import com.google.gct.directaccess.DirectAccessPersistentStateComponent
import com.google.gct.directaccess.DirectAccessService
import com.google.gct.directaccess.FULL_PERMISSIONS_SET
import com.google.gct.directaccess.directAccessCloudProjectManager
import com.google.gct.directaccess.provisioner.DeviceInfo
import com.google.gct.directaccess.provisioner.DeviceSelection
import com.google.gct.directaccess.provisioner.DirectAccessDeviceHandle
import com.google.gct.login.LoginState
import com.google.gct.login2.GoogleLoginService
import com.google.gct.login2.LoginFeature
import com.google.services.firebase.FirebaseLoginFeature
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.ide.HelpTooltip
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.HyperlinkLabel
import com.intellij.ui.SearchTextField
import com.intellij.ui.TitledSeparator
import com.intellij.ui.components.AnActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.panels.HorizontalLayout
import com.intellij.ui.components.panels.VerticalLayout
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.EmptySpacingConfiguration
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.util.maximumHeight
import com.intellij.ui.util.minimumHeight
import com.intellij.ui.util.preferredHeight
import com.intellij.ui.util.preferredWidth
import com.intellij.util.applyIf
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import icons.StudioIcons
import java.awt.BorderLayout
import java.awt.CardLayout
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.event.DocumentEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.VisibleForTesting
private const val CLOUD_TEST_API_ENABLE_LINK =
"https://console.developers.google.com/apis/api/testing.googleapis.com/overview?project="
private const val SELECTION_TABLE_MINIMUM_HEIGHT = 385
const val ONBOARDING_WORKFLOW_KEY = "direct.access.onboarding"
private val PRESELECTED_DEVICE_KEY_SET = setOf("shiba/34", "felix/33", "b0q/33", "gts8uwifi/33")
val userSpecificFirebaseConsoleLink: String
get() = "https://console.firebase.google.com?authuser=${service<GoogleLoginService>().getEmail()}"
const val VIEW_PRICING_DETAILS_LINK = "https://d.android.com/r/studio-ui/device-streaming/pricing"
class SelectDeviceDialog(private val project: Project) : DialogWrapper(false) {
val scope = project.service<DirectAccessService>().scope.createChildScope(true)
private val uiDispatcher: CoroutineDispatcher
get() = AndroidDispatchers.uiThread(ModalityState.any())
private val loginLink =
AnActionLink(
if (service<GoogleLoginService>().isLoggedIn()) "Authorize Firebase" else "Log in to Google",
object : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
LoginFeature.feature<FirebaseLoginFeature>()
.logInAsync(
parentComponent =
e.dataContext.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT) as? JComponent
)
}
},
)
private val viewAllProjectsHyperlink =
HyperlinkLabel("View All Projects").apply {
setHyperlinkTarget(userSpecificFirebaseConsoleLink)
}
private val viewPricingDetailsHyperlink =
HyperlinkLabel("View Pricing Details").apply { setHyperlinkTarget(VIEW_PRICING_DETAILS_LINK) }
private val isDirectAccessEnabled =
(if (service<GoogleLoginService>().useOldVersion) service<LoginState>().loginStatus
else service<GoogleLoginService>().activeUserFlow)
.map { LoginFeature.feature<FirebaseLoginFeature>().isLoggedIn() }
.stateIn(
scope,
SharingStarted.Eagerly,
LoginFeature.feature<FirebaseLoginFeature>().isLoggedIn(),
)
@VisibleForTesting
val deviceTable =
CategoryTable(SelectDeviceTableColumns.columns, primaryKey = { it.deviceInfo.key })
private val searchTextField =
SearchTextField(false).apply {
// If the table is empty and the user decides to resize the dialog,
// searchTextField gets resized. Set max height to preferred height to avoid that.
maximumHeight = preferredHeight
addDocumentListener(
object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
updateTable()
}
}
)
}
private val searchText: String
get() = searchTextField.text
private var deviceRowDataList: List<SelectDeviceRowData> = emptyList()
/** Holds the initial state of the project selector in case the user decides to click cancel */
private var initialDialogState: InitialDialogState? = null
private fun updateDeviceRowDataList(wasDeviceRowDataListUpdated: Boolean = true) {
val accessibleDeviceInfoSet =
project.directAccessCloudProjectManager
?.accessibleDeviceInfoListFlow
?.stateFlow
?.value
?.map { it.key }
?.toSet() ?: setOf()
val oldSelectedDeviceInfoSet =
deviceRowDataList.filter { it.isSelected }.map { it.deviceInfo.key }.toSet()
deviceRowDataList =
project
.service<DirectAccessService>()
.deviceSelectionListFlow
.value
.map {
SelectDeviceRowData(
it.deviceInfo.key in accessibleDeviceInfoSet,
if (wasDeviceRowDataListUpdated) it.deviceInfo.key in oldSelectedDeviceInfoSet
else it.isSelected,
it.deviceInfo,
)
}
.sortedBy { it.deviceInfo.title }
if (
!PropertiesComponent.getInstance().getBoolean(ONBOARDING_WORKFLOW_KEY, false) &&
deviceRowDataList.isNotEmpty() &&
deviceRowDataList.count { it.isSelected } == 0
) {
var count = 0
deviceRowDataList.forEach {
if (it.isEnabled && it.deviceInfo.key in PRESELECTED_DEVICE_KEY_SET) {
it.isSelected = true
++count
}
}
if (count > 0) {
PropertiesComponent.getInstance().setValue(ONBOARDING_WORKFLOW_KEY, true)
}
}
}
private fun updateTable() {
deviceTable.values.forEach { deviceTable.removeRow(it) }
deviceRowDataList.filter { it.applySearchFilter() }.forEach { deviceTable.addOrUpdateRow(it) }
}
/** Updates the device list followed by updating the table */
private fun refreshTableData() {
updateDeviceRowDataList()
updateTable()
}
init {
title = "Configure Device Streaming"
updateDeviceRowDataList(false)
init()
}
private fun SelectDeviceRowData.applySearchFilter(): Boolean {
if (searchText.isEmpty()) return true
val words = searchText.split(Regex(" +"))
return words.all {
with(deviceInfo) {
// Check in title string in place of checking separately in manufacturer and name for cases
// where user types "Google Pixel"
title.contains(it, true) ||
// Match exact for numeric columns.
api.toString() == it ||
screenX.toString() == it ||
screenY.toString() == it ||
screenDensity.toString() == it
}
}
}
override fun dispose() {
super.dispose()
scope.cancel()
}
override fun createCenterPanel(): JComponent {
val topPanel = JPanel(VerticalLayout(5))
topPanel.add(createTitleLabel("Android Device Streaming"))
topPanel.add(
panel {
customizeSpacingConfiguration(EmptySpacingConfiguration()) {
row {
text(
"Android Device Streaming, powered by Firebase, provides secure direct ADB access to a wide range of Android devices," +
" which you can use to debug and interact with your app. <br>" +
"Android Device Streaming is a Beta service and may encounter service disruptions or issues as performance improves." +
" Select a Firebase Spark plan project for limited access at no cost," +
" or select a Blaze project for pay-as-you-go access that’s billed monthly. " +
"<a href=https://d.android.com/r/studio-ui/device-streaming/help>Learn more</a>"
)
.apply { align(Align.FILL) }
}
}
}
)
topPanel.add(TitledSeparator("Firebase Project Information"))
topPanel.add(createSelectProjectComponent())
topPanel.add(TitledSeparator("Select Devices"))
topPanel.add(
panel {
customizeSpacingConfiguration(EmptySpacingConfiguration()) {
row {
text(
"Select the devices you want to access. The devices you select are added to the Device Manager " +
"and deploy target dropdown menu in the main toolbar. There is no cost associated with this action."
)
}
}
}
)
val panel = JPanel(BorderLayout(0, 5))
panel.add(topPanel, BorderLayout.NORTH)
panel.add(createTableComponent(), BorderLayout.CENTER)
// TODO(b/323428328) configure colors properly.
TreeWalker(panel).descendantStream().forEach { it.background = null }
return panel
}
private fun createTitleLabel(text: String) =
JBLabel(text, JBLabel.LEFT).apply { font = JBFont.h2() }
private fun createSelectProjectComponent(): JPanel {
val panel = JPanel(VerticalLayout(5)).apply { border = JBUI.Borders.empty(5, 10) }
val chooseProjectPanel = JPanel(HorizontalLayout(5))
chooseProjectPanel.add(JBLabel("Choose Project:"))
val selectorLayout = CardLayout()
val selectorPanel = JPanel(selectorLayout)
val planLabel =
JBLabel().apply {
horizontalTextPosition = JBLabel.LEFT
font = JBFont.medium().asBold()
}
val planHelpIcon = JBLabel(AllIcons.General.ContextHelp)
val planPanel =
JPanel(HorizontalLayout(5)).apply {
border = JBUI.Borders.empty(1, 0)
add(planLabel)
add(planHelpIcon)
}
val usedMinutesLabel = JBLabel()
val remainingMinutesLabel = JBLabel().apply { foreground = UIUtil.getLabelInfoForeground() }
val informationLabel =
JBLabel("Estimated minutes based on usage across all Firebase project members.").apply {
foreground = UIUtil.getLabelInfoForeground()
}
updateRemainingQuota(usedMinutesLabel, remainingMinutesLabel, null)
updatePlan(planLabel, planHelpIcon, null)
val updateSelector: (Boolean) -> Unit = { enabled ->
selectorPanel.add(
if (enabled) {
val preferredProject =
project.service<DirectAccessService>().cloudProjectManager.value?.cloudProject?.name
?: project.service<DirectAccessPersistentStateComponent>().state.selectedCloudProject
?: ""
val selection = project.service<DirectAccessService>().deviceSelectionListFlow.value
initialDialogState = InitialDialogState(preferredProject, selection)
val component = JPanel(HorizontalLayout(5))
val selector =
DirectAccessProjectSelectorImpl(
project,
preferredProject,
project
.service<DeviceProvisionerService>()
.deviceProvisioner
.devices
.value
.filterIsInstance<DirectAccessDeviceHandle>()
.none {
// Disable the selector if there are connecting or connected devices.
it.state is DeviceState.Connected ||
(it.state is DeviceState.Disconnected && it.state.isTransitioning)
},
scope,
)
component.add(selector.component)
val errorIcon =
JBLabel().apply {
icon = StudioIcons.Common.ERROR
isVisible = false
}
component.add(errorIcon)
scope.launch {
selector.isReady.takeWhile { !it }.collect()
selector.selectedProject.collect {
onProjectChanged(
project,
it,
panel,
errorIcon,
planLabel,
planHelpIcon,
usedMinutesLabel,
remainingMinutesLabel,
)
withContext(uiDispatcher) {
refreshTableData()
panel.revalidate()
panel.repaint()
}
}
}
component
} else {
loginLink
},
enabled.toString(),
)
selectorLayout.show(selectorPanel, enabled.toString())
}
var isNowEnabled = isDirectAccessEnabled.value
updateSelector(isDirectAccessEnabled.value)
val usagePanel =
JPanel(HorizontalLayout(5)).apply {
add(usedMinutesLabel)
add(remainingMinutesLabel)
add(viewPricingDetailsHyperlink)
}
val viewAllProjectsPanel =
JPanel(HorizontalLayout(0)).apply {
add(viewAllProjectsHyperlink, HorizontalLayout.LEFT)
isVisible = isDirectAccessEnabled.value
}
scope.launch {
isDirectAccessEnabled.collect {
if (it != isNowEnabled) {
isNowEnabled = it
updateSelector(isNowEnabled)
viewAllProjectsPanel.isVisible = isNowEnabled
}
}
}
chooseProjectPanel.add(selectorPanel)
panel.add(chooseProjectPanel)
panel.add(viewAllProjectsPanel)
panel.add(planPanel)
panel.add(usagePanel)
panel.add(informationLabel)
return panel
}
private fun createTableComponent() =
JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(searchTextField)
deviceRowDataList.forEach { deviceTable.addOrUpdateRow(it) }
add(JBScrollPane().apply { deviceTable.addToScrollPane(this) })
searchTextField.preferredWidth = preferredWidth
// Show a smaller window for an appropriate size of dialog.
// Showing all devices causes the dialog to be very tall.
minimumHeight = SELECTION_TABLE_MINIMUM_HEIGHT
preferredHeight = minimumHeight
scope.launch(uiDispatcher) {
project.service<DirectAccessService>().deviceSelectionListFlow.collect {
refreshTableData()
}
}
}
private suspend fun onProjectChanged(
project: Project,
cloudProject: String,
parent: JPanel,
errorIcon: JBLabel,
planLabel: JBLabel,
planHelpIcon: JBLabel,
usedMinutesLabel: JBLabel,
remainingMinutesLabel: JBLabel,
) {
errorIcon.isVisible = false
if (cloudProject == ERROR_FETCHING_FIREBASE_PROJECT) {
withContext(AndroidDispatchers.uiThread) { parent.revalidate() }
return
} else if (cloudProject.isEmpty() || cloudProject == NO_PROJECTS_AVAILABLE) {
project.service<DirectAccessService>().selectCloudProject(null)
return
}
project.service<DirectAccessService>().selectCloudProject(cloudProject)
withContext(uiDispatcher) {
parent.revalidate()
launch {
val permission =
project.directAccessCloudProjectManager?.permissionFlow?.value
?: throw RuntimeException("Unable to retrieve permission")
val reservationListException =
project.directAccessCloudProjectManager?.reservationListFlowWithException?.value?.second
val errorMessage = getErrorMessage(cloudProject, permission, reservationListException)
if (errorMessage != null) {
val (linkText, link) =
when {
errorMessage.contains("Google Cloud console") ->
Pair("Google Cloud console", "$CLOUD_TEST_API_ENABLE_LINK$cloudProject")
else ->
Pair(
"Learn More",
"http://d.android.com/r/studio-ui/device-streaming/help/permissions",
)
}
HelpTooltip()
.setDescription(errorMessage)
.setLink(linkText) { BrowserUtil.browse(link) }
.installOn(errorIcon)
errorIcon.isVisible = true
errorIcon.revalidate()
errorIcon.repaint()
updateRemainingQuota(usedMinutesLabel, remainingMinutesLabel, null)
updatePlan(planLabel, planHelpIcon, null)
} else {
errorIcon.toolTipText = ""
errorIcon.isVisible = false
launch {
val isBillingEnabled =
project.directAccessCloudProjectManager?.isBillingEnabledFlow?.value
updateRemainingQuota(
usedMinutesLabel,
remainingMinutesLabel,
withContext(Dispatchers.IO) {
try {
project.directAccessCloudProjectManager?.usageQuota
} catch (e: Exception) {
null
}
},
isBillingEnabled,
)
updatePlan(planLabel, planHelpIcon, isBillingEnabled)
parent.revalidate()
}
}
parent.revalidate()
}
}
}
private fun getErrorMessage(
cloudProject: String,
permission: DirectAccessPermissionStatus,
exception: Exception?,
): String? {
return if (exception != null) {
getErrorMessageFromException(cloudProject, permission, exception)
} else {
when (permission) {
is DirectAccessPermissionStatus.None ->
"You do not have access to Device Streaming in project $cloudProject."
is DirectAccessPermissionStatus.Viewer,
is DirectAccessPermissionStatus.MissingServiceUse,
is DirectAccessPermissionStatus.Unknown ->
"You do not have full access to Device Streaming in project $cloudProject. You are missing the following permissions:<br>" +
permission.missingPermissions.joinToString("<br>")
is DirectAccessPermissionStatus.Full -> null
}
}
}
private fun getErrorMessageFromException(
cloudProject: String,
permission: DirectAccessPermissionStatus,
exception: Exception,
) =
if (exception is StatusRuntimeException) {
getStatusCodeErrorMessage(cloudProject, permission, exception)
} else {
"An unknown error occurred when checking your permissions."
}
private fun getStatusCodeErrorMessage(
cloudProject: String,
permission: DirectAccessPermissionStatus,
exception: StatusRuntimeException,
) =
if (exception.status.code == Status.Code.PERMISSION_DENIED) {
val description = exception.status.description
val apiDisabledString =
"Cloud Testing API has not been used in project $cloudProject before or it is disabled."
val serviceUsageMissing = "Grant the caller the roles/serviceusage.serviceUsageConsumer role"
when {
description?.contains(apiDisabledString, true) == true ->
"Cloud Testing API is not enabled in your project $cloudProject. Enable it by visiting Google Cloud console."
description?.contains(serviceUsageMissing, true) == true -> {
if (permission.missingPermissions == FULL_PERMISSIONS_SET) {
"You do not have access to Device Streaming in project $cloudProject."
} else {
"You do not have full access to Device Streaming in project $cloudProject. You are missing the following permissions:<br>" +
permission.missingPermissions.joinToString("<br>")
}
}
else -> "You do not have access to Device Streaming in project $cloudProject."
}
} else {
"An unknown error occurred when checking your permissions."
}
private fun updateRemainingQuota(
usedMinutesLabel: JBLabel,
remainingMinutesLabel: JBLabel,
quota: Pair<Long, Long>?,
isBillingEnabled: Boolean? = null,
) {
usedMinutesLabel.text = "${quota?.first?.toString() ?: "--" } mins used"
if (isBillingEnabled == true) {
remainingMinutesLabel.text = "Blaze Plan may incur charges"
} else {
val remainingText =
quota?.let {
val remainingMinutes = it.second - it.first
when {
remainingMinutes <= 0 -> "0"
remainingMinutes < 15 -> "less than 15"
else -> remainingMinutes.toString()
}
} ?: "--"
remainingMinutesLabel.text = "$remainingText mins remaining"
}
}
private fun updatePlan(planLabel: JBLabel, helpIcon: JBLabel, isBillingEnabled: Boolean?) {
planLabel.text = isBillingEnabled?.let { if (it) "Blaze Plan" else "Spark Plan" } ?: "Plan: -"
var description =
when (isBillingEnabled) {
true -> "This project is on the Blaze plan."
false -> "Spark plans provide limited usage at no cost."
null -> "Billing information not available."
}
when (isBillingEnabled) {
true -> description = "Blaze plans allow extended usage and is billed monthly."
false ->
description +=
" Switch to a Blaze plan with monthly billing to keep using the service after Spark minutes run out."
else -> {}
}
HelpTooltip()
.setDescription(description)
.applyIf(isBillingEnabled != null) {
val linkText =
when (isBillingEnabled!!) {
true -> "View Pricing"
false -> "Learn More..."
}
val link =
when (isBillingEnabled) {
true -> VIEW_PRICING_DETAILS_LINK
false -> "https://d.android.com/r/studio-ui/device-streaming/firebase-plans"
}
setLink(linkText) { BrowserUtil.browse(link) }
}
.installOn(helpIcon)
}
override fun doOKAction() {
super.doOKAction()
val selectedDeviceInfoSet =
deviceRowDataList.filter { it.isSelected }.map { it.deviceInfo }.toSet()
project.service<DirectAccessService>().deviceSelectionListFlow.update {
it.map { deviceSelection ->
DeviceSelection(
deviceSelection.deviceInfo in selectedDeviceInfoSet,
deviceSelection.deviceInfo,
)
}
}
}
override fun doCancelAction() {
super.doCancelAction()
initialDialogState?.let { initialState ->
val directAccessService = project.service<DirectAccessService>()
directAccessService.selectCloudProject(initialState.cloudProject)
directAccessService.deviceSelectionListFlow.update { initialState.deviceSelection }
}
}
private val DeviceInfo.title: String
get() = "$manufacturer $name"
}
private data class InitialDialogState(
val cloudProject: String,
val deviceSelection: List<DeviceSelection>,
)