blob: 2575c88ab98057ac0d8932a9a61546c684d7b3ff [file] [log] [blame]
/*
* 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.sdklib.deviceprovisioner
import com.android.adblib.AdbSession
import com.android.adblib.ConnectedDevice
import com.android.adblib.DevicePropertyNames
import com.android.adblib.adbLogger
import com.android.adblib.deviceProperties
import com.android.adblib.scope
import com.android.adblib.serialNumber
import com.android.adblib.tools.EmulatorConsole
import com.android.adblib.tools.localConsoleAddress
import com.android.adblib.tools.openEmulatorConsole
import com.android.adblib.utils.createChildScope
import com.android.annotations.concurrency.GuardedBy
import com.android.sdklib.AndroidVersion
import com.android.sdklib.SdkVersionInfo
import com.android.sdklib.SystemImageTags
import com.android.sdklib.deviceprovisioner.DeviceState.Connected
import com.android.sdklib.deviceprovisioner.DeviceState.Disconnected
import com.android.sdklib.devices.Abi
import com.android.sdklib.internal.avd.AvdInfo
import com.android.sdklib.internal.avd.AvdInfo.AvdStatus
import com.android.sdklib.internal.avd.AvdManager.USER_SETTINGS_INI_PREFERRED_ABI
import com.android.sdklib.internal.avd.HardwareProperties
import com.google.wireless.android.sdk.stats.DeviceInfo
import com.intellij.icons.AllIcons
import java.io.IOException
import java.nio.file.Path
import java.time.Duration
import javax.swing.Icon
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Provides access to emulators running on the local machine from the standard AVD directory.
* Supports creating, editing, starting, and stopping AVD instances.
*
* This plugin creates device handles for all AVDs present in the standard AVD directory, running or
* not. The AVD path is used to identify devices and establish the link between connected devices
* and their handles. The directory is periodically rescanned to find new devices, and immediately
* rescanned after an edit is made via a device action.
*/
class LocalEmulatorProvisionerPlugin(
private val scope: CoroutineScope,
private val adbSession: AdbSession,
private val avdManager: AvdManager,
private val deviceIcons: DeviceIcons,
private val defaultPresentation: DeviceAction.DefaultPresentation,
private val diskIoDispatcher: CoroutineDispatcher,
rescanPeriod: Duration = Duration.ofSeconds(10),
) : DeviceProvisionerPlugin {
val logger = adbLogger(adbSession)
companion object {
const val PLUGIN_ID = "LocalEmulator"
}
/**
* An abstraction of the AvdManager / AvdManagerConnection classes to be injected, allowing for
* testing and decoupling from Studio.
*/
interface AvdManager {
suspend fun rescanAvds(): List<AvdInfo>
suspend fun createAvd(): AvdInfo?
suspend fun editAvd(avdInfo: AvdInfo): AvdInfo?
suspend fun startAvd(avdInfo: AvdInfo)
suspend fun coldBootAvd(avdInfo: AvdInfo)
suspend fun bootAvdFromSnapshot(avdInfo: AvdInfo, snapshot: LocalEmulatorSnapshot)
suspend fun stopAvd(avdInfo: AvdInfo)
suspend fun showOnDisk(avdInfo: AvdInfo)
suspend fun duplicateAvd(avdInfo: AvdInfo)
suspend fun wipeData(avdInfo: AvdInfo)
suspend fun deleteAvd(avdInfo: AvdInfo)
suspend fun downloadAvdSystemImage(avdInfo: AvdInfo)
}
// We can identify local emulators reliably, so this can be relatively high priority.
override val priority = 100
private val mutex = Mutex()
@GuardedBy("mutex") private val deviceHandles = HashMap<Path, LocalEmulatorDeviceHandle>()
private val _devices = MutableStateFlow<List<DeviceHandle>>(emptyList())
override val devices: StateFlow<List<DeviceHandle>> = _devices.asStateFlow()
// TODO: Consider if it would be better to use a filesystem watcher here instead of polling.
private val avdScanner = PeriodicAction(scope, rescanPeriod, ::rescanAvds)
init {
avdScanner.runNow()
scope.coroutineContext.job.invokeOnCompletion { avdScanner.cancel() }
}
/**
* Scans the AVDs on disk and updates our devices.
*
* Do not call directly; this should only be called by PeriodicAction.
*/
private suspend fun rescanAvds() {
val avdsOnDisk = avdManager.rescanAvds().associateBy { it.dataFolderPath }
mutex.withLock {
// Remove any current DeviceHandles that are no longer present on disk, unless they are
// connected. (If a client holds on to the disconnected device handle, and it gets
// recreated with the same path, the client will get a new device handle, which is fine.)
val iterator = deviceHandles.entries.iterator()
while (iterator.hasNext()) {
val (path, handle) = iterator.next()
if (!avdsOnDisk.containsKey(path) && handle.state is Disconnected) {
iterator.remove()
handle.scope.cancel()
}
}
for ((path, avdInfo) in avdsOnDisk) {
when (val handle = deviceHandles[path]) {
null ->
deviceHandles[path] =
LocalEmulatorDeviceHandle(scope.createChildScope(isSupervisor = true), avdInfo)
else -> handle.updateAvdInfo(avdInfo)
}
}
_devices.value = deviceHandles.values.toList()
}
}
private fun disconnectedDeviceProperties(avdInfo: AvdInfo): LocalEmulatorProperties =
LocalEmulatorProperties.build(avdInfo) {
populateDeviceInfoProto(PLUGIN_ID, null, emptyMap(), "")
icon = deviceIcons.iconForDeviceType(deviceType)
}
override suspend fun claim(device: ConnectedDevice): DeviceHandle? {
val result = LOCAL_EMULATOR_REGEX.matchEntire(device.serialNumber) ?: return null
val port = result.groupValues[1].toIntOrNull() ?: return null
logger.debug { "Opening emulator console to $port" }
val emulatorConsole =
withTimeoutOrNull(5.seconds) { adbSession.openEmulatorConsole(localConsoleAddress(port)) }
?: return null
// This will fail on emulator versions prior to 30.0.18.
val pathResult = kotlin.runCatching { emulatorConsole.avdPath() }
val path = pathResult.getOrNull()
if (path == null) {
// If we can't connect to the emulator console, this isn't operationally a local emulator
logger.debug { "Unable to read path for device ${device.serialNumber} from emulator console" }
emulatorConsole.close()
return null
}
// Try to link this device to an existing handle.
var handle = mutex.withLock { deviceHandles[path] }
if (handle == null) {
// We didn't read this path from disk yet. Rescan and try again.
avdScanner.runNow().join()
handle = mutex.withLock { deviceHandles[path] }
}
if (handle == null) {
// Apparently this emulator is not on disk, or it is not in the directory that we scan for
// AVDs. (Perhaps GMD or Crow failed to pick it up.)
logger.debug { "Unexpected device at $path" }
emulatorConsole.close()
return null
}
handle.updateConnectedDevice(device, emulatorConsole, port)
logger.debug { "Linked ${device.serialNumber} to AVD at $path" }
// Wait for the handle to update its state to Connected before returning, so that the
// provisioner doesn't think it needs to re-offer the device. This should happen almost
// instantly.
withTimeoutOrNull(2.seconds) { handle.stateFlow.first { it.connectedDevice == device } }
?: logger.warn("Device ${device.serialNumber} did not become connected!")
return handle
}
fun refreshDevices() {
avdScanner.runNow()
}
override val createDeviceAction =
object : CreateDeviceAction {
override val presentation =
MutableStateFlow(defaultPresentation.fromContext().copy(label = "Create Virtual Device"))
.asStateFlow()
override suspend fun create() {
if (avdManager.createAvd() != null) {
refreshDevices()
}
}
}
/** The mutable state of a LocalEmulatorDeviceHandle, emitted by the internalStateFlow. */
private data class InternalState(
val deviceState: DeviceState,
val emulatorConsole: EmulatorConsole?,
val avdInfo: AvdInfo,
val pendingAvdInfo: AvdInfo?,
)
/**
* A handle for a local AVD stored in the SDK's AVD directory. These are only created when reading
* an AVD off the disk; only devices that have already been read from disk will be claimed.
*/
private inner class LocalEmulatorDeviceHandle(
override val scope: CoroutineScope,
initialAvdInfo: AvdInfo,
initialDeviceProperties: LocalEmulatorProperties = disconnectedDeviceProperties(initialAvdInfo),
val clock: Clock = Clock.System,
) : DeviceHandle {
private val messageChannel: Channel<LocalEmulatorMessage> = Channel()
override val id = DeviceId(PLUGIN_ID, false, "path=${initialAvdInfo.dataFolderPath}")
/**
* The mutable state of the handle, maintained by an actor coroutine which reads from
* [messageChannel] serially, and emits the resulting changes on this flow.
*/
private val internalStateFlow: StateFlow<InternalState> =
flow {
var activeAvdInfo = initialAvdInfo
var pendingAvdInfo: AvdInfo? = null
var connectedDevice: ConnectedDevice? = null
var emulatorConsole: EmulatorConsole? = null
var emulatorConsolePort: Int? = null
var connectedDeviceJobScope: CoroutineScope? = null
var connectedDeviceState: com.android.adblib.DeviceState? = null
var pendingTransition: TransitionRequest? = null
var bootStatus = false
var properties = initialDeviceProperties
fun logName(): String {
val port = emulatorConsolePort?.let { " ($it)" } ?: ""
return "[${properties.title}$port]"
}
for (message in messageChannel) {
logger.debug { "${logName()} Processing: $message" }
when (message) {
is AvdInfoUpdate -> {
if (!activeAvdInfo.isSameMetadata(message.avdInfo)) {
activeAvdInfo = activeAvdInfo.copyMetadata(message.avdInfo)
properties = properties.toBuilder().apply { setAvdInfo(activeAvdInfo) }.build()
}
if (connectedDevice == null) {
if (activeAvdInfo != message.avdInfo) {
activeAvdInfo = message.avdInfo
properties = disconnectedDeviceProperties(activeAvdInfo)
}
} else if (pendingAvdInfo != null || activeAvdInfo != message.avdInfo) {
pendingAvdInfo = message.avdInfo
}
}
is ConnectedDeviceUpdate -> {
connectedDevice = message.connectedDevice
emulatorConsole?.close()
emulatorConsole = message.emulatorConsole
emulatorConsolePort = message.emulatorConsolePort
if (pendingTransition?.transitionType == TransitionType.ACTIVATION) {
pendingTransition.completion.complete(Unit)
pendingTransition = null
}
// Spawn jobs to track boot status and device status
connectedDeviceJobScope?.cancel()
connectedDeviceJobScope = scope.createChildScope(isSupervisor = true)
connectedDeviceJobScope.launch {
message.connectedDevice
.bootStatusFlow()
.catch { e -> logger.warn(e, "${logName()} Failed to read boot status") }
.collect { messageChannel.send(BootStatusUpdate(it)) }
}
connectedDeviceJobScope.launch {
message.connectedDevice.deviceInfoFlow
.onEach { messageChannel.send(ConnectedDeviceStateUpdate(it.deviceState)) }
.takeWhile { it.deviceState != com.android.adblib.DeviceState.DISCONNECTED }
.collect()
}
}
is ConnectedDeviceStateUpdate -> {
connectedDeviceState = message.deviceState
if (connectedDeviceState == com.android.adblib.DeviceState.DISCONNECTED) {
logger.debug { "${logName()} Device closed; disconnecting from console" }
emulatorConsole?.close()
emulatorConsole = null
connectedDevice = null
if (pendingTransition?.transitionType == TransitionType.DEACTIVATION) {
pendingTransition.completion.complete(Unit)
pendingTransition = null
}
bootStatus = false
if (pendingAvdInfo != null) {
activeAvdInfo = pendingAvdInfo
pendingAvdInfo = null
}
properties = disconnectedDeviceProperties(activeAvdInfo)
}
}
is BootStatusUpdate -> {
// On a transition from not booted to booted, read the properties from the device.
// bootStatus is always reset when connectedDevice becomes null, so connectedDevice
// is guaranteed to become non-null before bootStatus becomes true
val connectedDevice = connectedDevice
if (connectedDevice != null && !bootStatus && message.bootStatus.isBooted) {
// Spawn a job to do I/O with the device. Run on device scope so that if the
// device disconnects, the job is cancelled.
connectedDevice.scope.launch {
messageChannel.send(
DevicePropertiesUpdate(
connectedDevice,
runCatching { connectedDevice.deviceProperties().all().asMap() },
Resolution.readFromDevice(connectedDevice),
)
)
}
}
}
is DevicePropertiesUpdate -> {
if (message.connectedDevice == connectedDevice) {
bootStatus = true
val newProperties = message.properties.getOrNull()
if (newProperties == null) {
val e = message.properties.exceptionOrNull()
logger.warn(e, "Unable to read device properties")
} else {
properties =
LocalEmulatorProperties.build(activeAvdInfo) {
readCommonProperties(newProperties)
populateDeviceInfoProto(
PLUGIN_ID,
connectedDevice.serialNumber,
newProperties,
randomConnectionId(),
)
// Device type is not always reliably read from properties
deviceType = activeAvdInfo.toDeviceType()
density =
newProperties[DevicePropertyNames.QEMU_SF_LCD_DENSITY]?.toIntOrNull()
resolution = message.resolution
disambiguator = emulatorConsolePort.toString()
wearPairingId =
activeAvdInfo.dataFolderPath.toString().takeIf { isPairable() }
icon = deviceIcons.iconForDeviceType(deviceType)
}
}
}
}
is TransitionRequest -> {
if (pendingTransition == null) {
val transitionNecessary =
when (message.transitionType) {
TransitionType.ACTIVATION -> connectedDevice == null
TransitionType.DEACTIVATION -> connectedDevice != null
}
if (transitionNecessary) {
pendingTransition = message
scope.launch {
messageChannel.send(TransitionResult(runCatching { message.action() }))
}
scheduleTimeoutCheck(message.timeout)
} else {
// We are already in the desired state; this is a no-op
message.completion.complete(Unit)
}
} else {
message.completion.completeExceptionally(
DeviceActionDisabledException(
"Device is already " +
when (pendingTransition.transitionType) {
TransitionType.ACTIVATION -> "activating"
TransitionType.DEACTIVATION -> "deactivating"
}
)
)
}
}
is TransitionResult -> {
// Closure-based approaches (e.g. onFailure) inhibit smart-cast on pendingTransition
val e = message.result.exceptionOrNull()
if (e != null) {
// Note that we only complete on exception; if it succeeded we still wait for the
// state to change before we signal completion
pendingTransition?.completion?.completeExceptionally(e)
pendingTransition = null
}
}
is CheckTimeout -> {
if (pendingTransition != null && pendingTransition.timeout > clock.now()) {
val action =
when (pendingTransition.transitionType) {
TransitionType.ACTIVATION -> "connect"
TransitionType.DEACTIVATION -> "disconnect"
}
pendingTransition.completion.completeExceptionally(
DeviceActionException(
"Emulator failed to $action within $CONNECTION_TIMEOUT_MINUTES minutes"
)
)
pendingTransition = null
}
}
}
emit(
InternalState(
if (connectedDevice == null) {
Disconnected(
properties,
isTransitioning = pendingTransition != null,
status = if (pendingTransition != null) "Starting up" else "Offline",
error = activeAvdInfo.deviceError,
)
} else {
Connected(
properties,
isTransitioning = !bootStatus || pendingTransition != null,
isReady =
bootStatus && connectedDeviceState == com.android.adblib.DeviceState.ONLINE,
status =
when {
pendingTransition != null -> "Shutting down"
!bootStatus -> "Booting"
else -> "Connected"
},
connectedDevice,
error = activeAvdInfo.deviceError ?: pendingAvdInfo?.let { AvdChangedError },
)
},
emulatorConsole,
activeAvdInfo,
pendingAvdInfo,
)
)
}
}
.onCompletion { emulatorConsole?.close() }
.stateIn(
scope,
SharingStarted.Eagerly,
InternalState(
Disconnected(initialDeviceProperties, error = initialAvdInfo.deviceError),
null,
initialAvdInfo,
null,
),
)
override val stateFlow =
internalStateFlow
.map { it.deviceState }
.stateIn(scope, SharingStarted.Eagerly, Disconnected(initialDeviceProperties))
/** The currently active AvdInfo for the device. */
val avdInfo: AvdInfo
get() = internalStateFlow.value.avdInfo
/**
* The latest AvdInfo read from the disk for the device. If the on-disk AvdInfo is updated while
* the device is already running, the device will continue to reflect the AvdInfo from its boot
* time.
*/
private val onDiskAvdInfo: AvdInfo
get() = internalStateFlow.value.let { it.pendingAvdInfo ?: it.avdInfo }
/** The emulator console is present when the device is connected. */
private val emulatorConsole: EmulatorConsole?
get() = internalStateFlow.value.emulatorConsole
private fun scheduleTimeoutCheck(timeout: Instant) {
scope.launch {
delay(timeout - clock.now())
messageChannel.send(CheckTimeout)
}
}
/**
* Update the avdInfo if we're not currently running. If we are running, the old values are
* probably still in effect, but we will update on the next scan after shutdown.
*/
suspend fun updateAvdInfo(newAvdInfo: AvdInfo) {
messageChannel.send(AvdInfoUpdate(newAvdInfo))
}
/** Notifies the handle that it has been connected. */
suspend fun updateConnectedDevice(
connectedDevice: ConnectedDevice,
emulatorConsole: EmulatorConsole,
emulatorConsolePort: Int,
) {
messageChannel.send(
ConnectedDeviceUpdate(connectedDevice, emulatorConsole, emulatorConsolePort)
)
}
override val activationAction =
object : ActivationAction {
override val presentation = defaultPresentation.fromContext().enabledIfActivatable()
override suspend fun activate() {
activate { avdManager.startAvd(avdInfo) }
}
}
override val coldBootAction =
object : ColdBootAction {
override val presentation = defaultPresentation.fromContext().enabledIfActivatable()
override suspend fun activate() {
activate { avdManager.coldBootAvd(avdInfo) }
}
}
override val bootSnapshotAction =
object : BootSnapshotAction {
override val presentation = defaultPresentation.fromContext().enabledIfActivatable()
override suspend fun snapshots(): List<Snapshot> =
withContext(diskIoDispatcher) {
LocalEmulatorSnapshotReader(logger)
.readSnapshots(avdInfo.dataFolderPath.resolve("snapshots"))
}
override suspend fun activate(snapshot: Snapshot) {
activate { avdManager.bootAvdFromSnapshot(avdInfo, snapshot as LocalEmulatorSnapshot) }
}
}
private suspend fun activate(action: suspend () -> Unit) {
val request =
TransitionRequest(TransitionType.ACTIVATION, clock.now() + CONNECTION_TIMEOUT, action)
messageChannel.send(request)
// Use the Deferred to receive exceptions from the actor.
request.completion.await()
// We still need to wait very briefly for the state to update after the Deferred is completed.
// If completion was unexceptional, we will immediately transition to Connected.
stateFlow.first { it is Connected }
}
override val editAction =
object : EditAction {
override val presentation =
MutableStateFlow(defaultPresentation.fromContext()).asStateFlow()
override suspend fun edit() {
avdManager.editAvd(onDiskAvdInfo)?.let { refreshDevices() }
}
}
override val deactivationAction: DeactivationAction =
object : DeactivationAction {
// We could check this with AvdManagerConnection.isAvdRunning, but that's expensive, and if
// it's not running we should see it from ADB anyway
override val presentation =
defaultPresentation.fromContext().enabledIf { it is Connected && !it.isTransitioning }
override suspend fun deactivate() {
val request =
TransitionRequest(
TransitionType.DEACTIVATION,
clock.now() + DISCONNECTION_TIMEOUT,
::stop,
)
messageChannel.send(request)
request.completion.await()
stateFlow.first { it is Disconnected }
}
}
override val repairDeviceAction =
object : RepairDeviceAction {
override val presentation =
DeviceAction.Presentation(
label = "Download system image",
icon = AllIcons.Actions.Download,
enabled = false,
)
.enabledIf {
(it.error as? AvdDeviceError)?.status in
setOf(AvdStatus.ERROR_IMAGE_DIR, AvdStatus.ERROR_IMAGE_MISSING)
}
override suspend fun repair() {
avdManager.downloadAvdSystemImage(avdInfo)
refreshDevices()
}
}
/**
* Attempts to stop the AVD. We can either use the emulator console or AvdManager (which uses a
* shell command to kill the process)
*/
private suspend fun stop() {
emulatorConsole?.let {
try {
it.kill()
return
} catch (e: IOException) {
// Connection to emulator console is closed, possibly due to a harmless race condition.
logger.debug(e) { "Failed to shutdown via emulator console; falling back to AvdManager" }
}
}
avdManager.stopAvd(avdInfo)
}
override val showAction: ShowAction =
object : ShowAction {
override val presentation =
MutableStateFlow(defaultPresentation.fromContext().copy(label = "Show on Disk"))
override suspend fun show() {
avdManager.showOnDisk(avdInfo)
}
}
override val duplicateAction: DuplicateAction =
object : DuplicateAction {
override val presentation = MutableStateFlow(defaultPresentation.fromContext())
override suspend fun duplicate() {
avdManager.duplicateAvd(onDiskAvdInfo)
refreshDevices()
}
}
override val wipeDataAction: WipeDataAction =
object : WipeDataAction {
override val presentation = defaultPresentation.fromContext().enabledIfStopped()
override suspend fun wipeData() {
avdManager.wipeData(avdInfo)
}
}
override val deleteAction: DeleteAction =
object : DeleteAction {
override val presentation = defaultPresentation.fromContext().enabledIfStopped()
override suspend fun delete() {
avdManager.deleteAvd(avdInfo)
refreshDevices()
}
}
private fun DeviceAction.Presentation.enabledIf(condition: (DeviceState) -> Boolean) =
stateFlow
.map { this.copy(enabled = condition(it)) }
.stateIn(scope, SharingStarted.Eagerly, this)
private fun DeviceState.isStopped() = this is Disconnected && !this.isTransitioning
private fun DeviceAction.Presentation.enabledIfStopped() = enabledIf { it.isStopped() }
private fun DeviceAction.Presentation.enabledIfActivatable() = enabledIf {
it.isStopped() && it.error?.severity != DeviceError.Severity.ERROR
}
}
}
data class LocalEmulatorProperties(
override val manufacturer: String?,
override val model: String?,
override val androidVersion: AndroidVersion?,
override val abiList: List<Abi>,
override val preferredAbi: String?,
override val androidRelease: String?,
override val disambiguator: String?,
override val deviceType: DeviceType?,
override val isVirtual: Boolean?,
override val isRemote: Boolean?,
override val isDebuggable: Boolean?,
override val wearPairingId: String?,
override val resolution: Resolution?,
override val density: Int?,
override val icon: Icon,
override val connectionType: ConnectionType?,
override val deviceInfoProto: DeviceInfo,
val avdName: String,
val avdPath: Path,
val displayName: String,
val hasPlayStore: Boolean,
val avdConfigProperties: ImmutableMap<String, String>,
) : DeviceProperties {
override fun toBuilder(): Builder = Builder().apply { copyFrom(this@LocalEmulatorProperties) }
override val title = displayName
companion object {
inline fun build(avdInfo: AvdInfo, block: Builder.() -> Unit): LocalEmulatorProperties =
Builder()
.apply {
setAvdInfo(avdInfo)
block()
}
.build()
}
class Builder : DeviceProperties.Builder() {
var avdName: String? = null
var avdPath: Path? = null
var displayName: String? = null
var hasPlayStore: Boolean = false
val avdConfigProperties: MutableMap<String, String> = mutableMapOf()
fun copyFrom(properties: LocalEmulatorProperties) {
super.copyFrom(properties)
avdName = properties.avdName
avdPath = properties.avdPath
displayName = properties.displayName
hasPlayStore = properties.hasPlayStore
avdConfigProperties.clear()
avdConfigProperties.putAll(properties.avdConfigProperties)
}
fun isPairable(): Boolean {
val apiLevel = androidVersion?.apiLevel ?: return false
return when (deviceType) {
DeviceType.TV,
DeviceType.AUTOMOTIVE,
null -> false
DeviceType.HANDHELD -> apiLevel >= 30 && hasPlayStore
DeviceType.WEAR -> apiLevel >= 28
}
}
fun setAvdInfo(avdInfo: AvdInfo) {
isVirtual = true
manufacturer = avdInfo.deviceManufacturer
model = avdInfo.deviceName
androidVersion = avdInfo.androidVersion
androidRelease = SdkVersionInfo.getVersionString(avdInfo.androidVersion.apiLevel)
abiList = listOfNotNull(Abi.getEnum(avdInfo.abiType))
avdName = avdInfo.name
avdPath = avdInfo.dataFolderPath
displayName = avdInfo.displayName
deviceType = avdInfo.toDeviceType()
hasPlayStore = avdInfo.hasPlayStore()
wearPairingId = avdInfo.id.takeIf { isPairable() }
density = avdInfo.density
resolution = avdInfo.resolution
isDebuggable = !avdInfo.hasPlayStore()
preferredAbi = avdInfo.userSettings[USER_SETTINGS_INI_PREFERRED_ABI]
avdConfigProperties.putAll(avdInfo.properties)
}
override fun build() =
LocalEmulatorProperties(
manufacturer = manufacturer,
model = model,
androidVersion = androidVersion,
abiList = abiList,
preferredAbi = preferredAbi,
androidRelease = androidRelease,
disambiguator = disambiguator,
deviceType = deviceType,
isVirtual = isVirtual,
isRemote = isRemote,
isDebuggable = isDebuggable,
wearPairingId = wearPairingId,
resolution = resolution,
density = density,
icon = checkNotNull(icon),
connectionType = connectionType,
deviceInfoProto = deviceInfoProto.build(),
avdName = checkNotNull(avdName),
avdPath = checkNotNull(avdPath),
displayName = checkNotNull(displayName),
hasPlayStore = hasPlayStore,
avdConfigProperties = avdConfigProperties.toImmutableMap(),
)
}
}
private sealed interface LocalEmulatorMessage
private data class AvdInfoUpdate(val avdInfo: AvdInfo) : LocalEmulatorMessage
private data class ConnectedDeviceUpdate(
val connectedDevice: ConnectedDevice,
val emulatorConsole: EmulatorConsole,
val emulatorConsolePort: Int,
) : LocalEmulatorMessage
private data class ConnectedDeviceStateUpdate(val deviceState: com.android.adblib.DeviceState) :
LocalEmulatorMessage
private data class BootStatusUpdate(val bootStatus: BootStatus) : LocalEmulatorMessage
private data class DevicePropertiesUpdate(
val connectedDevice: ConnectedDevice,
val properties: Result<Map<String, String>>,
val resolution: Resolution?,
) : LocalEmulatorMessage
private data class TransitionRequest(
val transitionType: TransitionType,
val timeout: Instant,
val action: suspend () -> Unit,
val completion: CompletableDeferred<Unit> = CompletableDeferred(),
) : LocalEmulatorMessage
enum class TransitionType {
ACTIVATION,
DEACTIVATION
}
private data class TransitionResult(val result: Result<Any>) : LocalEmulatorMessage
private object CheckTimeout : LocalEmulatorMessage
private val AvdInfo.density
get() = properties[HardwareProperties.HW_LCD_DENSITY]?.toIntOrNull()
private val AvdInfo.resolution
get() =
properties[HardwareProperties.HW_LCD_WIDTH]?.toIntOrNull()?.let { width ->
properties[HardwareProperties.HW_LCD_HEIGHT]?.toIntOrNull()?.let { height ->
Resolution(width, height)
}
}
private val AvdInfo.deviceError
get() = errorMessage?.let { AvdDeviceError(status, it) }
private data class AvdDeviceError(val status: AvdStatus, override val message: String) :
DeviceError {
override val severity
get() =
when (status) {
AvdStatus.ERROR_DEVICE_MISSING -> DeviceError.Severity.INFO
else -> DeviceError.Severity.ERROR
}
}
internal object AvdChangedError : DeviceError {
override val severity = DeviceError.Severity.INFO
override val message = "Changes will apply on restart"
}
private fun AvdInfo.toDeviceType(): DeviceType {
val tags = tags
return when {
SystemImageTags.isTvImage(tags) -> DeviceType.TV
SystemImageTags.isAutomotiveImage(tags) -> DeviceType.AUTOMOTIVE
SystemImageTags.isWearImage(tags) -> DeviceType.WEAR
else -> DeviceType.HANDHELD
}
}
private val LOCAL_EMULATOR_REGEX = "emulator-(\\d+)".toRegex()
private const val CONNECTION_TIMEOUT_MINUTES: Long = 5
private val CONNECTION_TIMEOUT = CONNECTION_TIMEOUT_MINUTES.minutes
private const val DISCONNECTION_TIMEOUT_MINUTES: Long = 1
private val DISCONNECTION_TIMEOUT = DISCONNECTION_TIMEOUT_MINUTES.minutes