blob: 88867b86b86192c56837d268e9e38044f4618306 [file] [log] [blame]
/*
* Copyright (C) 2021 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.adblib
import com.android.adblib.AdbLibProperties.TRACK_DEVICES_RETRY_DELAY
import com.android.adblib.AdbSession.Companion.create
import com.android.adblib.CoroutineScopeCache.Key
import com.android.adblib.impl.AdbSessionImpl
import com.android.adblib.impl.ConnectedDevicesDeviceCacheProvider
import com.android.adblib.impl.ConnectedDevicesTrackerImpl
import com.android.adblib.impl.DeviceInfoTracker
import com.android.adblib.impl.SessionDeviceTracker
import com.android.adblib.impl.TrackerConnecting
import com.android.adblib.impl.TrackerDisconnected
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.coroutines.CoroutineContext
/**
* Provides access to various ADB services (e.g. [AdbHostServices], [AdbDeviceServices]) for
* a given [AdbSessionHost]. The [close] method should be called when the session is not needed
* anymore, to ensure all pending operations and all optional state are released.
*
* This is the main entry point of `adblib`, use the [create] method to create an instance.
*/
@IsThreadSafe
interface AdbSession : AutoCloseable {
/**
* The parent [AdbSession] if this session is a child session, `null` otherwise.
* If there is a parent session, [scope] is a guaranteed by be a child [CoroutineScope]
* of the parent session scope.
*/
val parentSession: AdbSession?
/**
* The [AdbSessionHost] implementation provided by the hosting application for environment
* specific configuration.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val host: AdbSessionHost
/**
* An [AdbChannelFactory] that can be used to create various implementations of
* [AdbChannel], [AdbInputChannel] and [AdbOutputChannel] for files, streams, etc.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val channelFactory: AdbChannelFactory
/**
* An [AdbHostServices] implementation for this session.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val hostServices: AdbHostServices
/**
* An [AdbDeviceServices] implementation for this session.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val deviceServices: AdbDeviceServices
/**
* [CoroutineScope] that remains active until the session is [closed][close].
*
* Useful when creating coroutines, e.g. with [CoroutineScope.launch], that need to be
* automatically cancelled when the session is [closed][close].
*
* The scope uses a [SupervisorJob] so that child jobs don't cancel the scope
* if they fail with an exception.
*
* The scope uses the [AdbSessionHost.ioDispatcher] dispatcher by default.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val scope: CoroutineScope
/**
* Thread safe in-memory cache to store objects for as long this [AdbSession] is active.
* Any value added to the cache that implements [AutoCloseable] is
* [closed][AutoCloseable.close] when this [AdbSession] is closed.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][close].
*/
val cache: CoroutineScopeCache
/**
* The coroutine dispatcher to use to execute asynchronous I/O and
* compute intensive operations.
*
* The default value is [Dispatchers.Default]
*
* @see AdbSessionHost.ioDispatcher
*/
val ioDispatcher
get() = host.ioDispatcher
/**
* The coroutine dispatcher to use to execute blocking I/O blocking operations.
*
* The default value is [Dispatchers.IO]
*
* @see AdbSessionHost.blockingIoDispatcher
*/
val blockingIoDispatcher
get() = host.blockingIoDispatcher
/**
* Throws [ClosedSessionException] if this [AdbSession] has been [closed][close].
*/
fun throwIfClosed()
companion object {
/**
* Creates an instance of an [AdbSession] given an [AdbSessionHost] instance.
*
* @param host The [AdbSessionHost] implementation provided by the hosting application for
* environment specific configuration
* @param channelProvider The [AdbServerChannelProvider] implementation to connect to the ADB server
* @param connectionTimeout The timeout to use when creating a connection the ADB Server
*/
@JvmStatic
fun create(
host: AdbSessionHost,
channelProvider: AdbServerChannelProvider = AdbServerChannelProvider.createOpenLocalHost(host),
connectionTimeout: Duration = Duration.ofSeconds(30)
): AdbSession {
return AdbSessionImpl(
parentSession = null,
host,
channelProvider,
connectionTimeout.toMillis()
)
}
/**
* Create a child [AdbSession] given its [parent session][parentSession]. The child session
* will use a child [CoroutineScope] of the parent session, but can use a distinct
* [host] and [channelProvider] if necessary.
*/
@JvmStatic
fun createChildSession(
parentSession: AdbSession,
host: AdbSessionHost,
channelProvider: AdbServerChannelProvider = AdbServerChannelProvider.createOpenLocalHost(host),
connectionTimeout: Duration = Duration.ofSeconds(30)
): AdbSession {
return AdbSessionImpl(
parentSession,
host,
channelProvider,
connectionTimeout.toMillis()
)
}
}
}
suspend fun <R> AdbSession.withErrorTimeout(
timeout: Long,
unit: TimeUnit,
block: suspend CoroutineScope.() -> R
): R {
return host.timeProvider.withErrorTimeout(timeout, unit, block)
}
suspend fun <R> AdbSession.withErrorTimeout(
timeout: Duration,
block: suspend CoroutineScope.() -> R
): R {
return host.timeProvider.withErrorTimeout(timeout, block)
}
fun <T: Any> AdbSession.property(property: AdbSessionHost.Property<T>): T {
return host.getPropertyValue(property)
}
/**
* Exception thrown when accessing services of an [AdbSession] that has been closed.
*/
class ClosedSessionException(message: String) : CancellationException(message)
/**
* Returns a [StateFlow] that emits a new [TrackedDeviceList] everytime a device state change
* is detected by the ADB Host.
*
* The implementation uses a [stateIn] operator with the following characteristics:
*
* * The initial value of the returned [StateFlow] is always an empty [TrackedDeviceList]
* of type [isTrackerConnecting]. This is because it may take some time to collect
* the initial list of devices.
*
* * The upstream flow is a [AdbHostServices.trackDevices] that is retried
* after a delay of [retryDelay] in case of error.
*
* * In case of error in the upstream flow, an empty [TrackedDeviceList] (of type
* [isTrackerDisconnected]) is emitted to downstream flows and the
* [TrackedDeviceList.connectionId] is incremented.
*
* * The returned [StateFlow] is unique to this [AdbSession], meaning all collectors of
* the returned flow share a single underlying [AdbHostServices.trackDevices] connection.
*
* * The returned [StateFlow] activates a [AdbHostServices.trackDevices] connection only when
* there are active downstream flows, see [SharingStarted.WhileSubscribed].
*
* * The returned [StateFlow] runs in a separate coroutine in the [AdbSession.scope].
* If the scope is cancelled, the [StateFlow] stops emitting new values, but downstream
* flows are not terminated. It is up to the caller to use an appropriate [CoroutineScope]
* when collecting the returned flow. A typical usage would be to use the
* [AdbSession.scope] when collecting, for example:
*
* ```
* val session: AdbSession
* session.scope.launch {
* session.trackDevices.flowOn(Dispatchers.Default).collect {
* // Collect until session scope is cancelled.
* }
* }
* ```
*
* * Downstream flows may not be able to collect all values produced by the [StateFlow],
* because [StateFlow] use the [BufferOverflow.DROP_OLDEST] strategy. Downstream flows
* should keep track of [TrackedDeviceList.connectionId] to determine if
* [DeviceInfo.serialNumber] values can be compared against previously collected
* serial numbers.
*
* * Because the [StateFlow] use the [BufferOverflow.DROP_OLDEST] strategy, slow downstream
* flows don't prevent other (more efficient) downstream flows from collecting new
* values when available.
*
* **Note**: [DeviceInfo] entries of the [DeviceList] are always of the
* [long format][AdbHostServices.DeviceInfoFormat.LONG_FORMAT].
*/
fun AdbSession.trackDevices(
retryDelay: Duration = property(TRACK_DEVICES_RETRY_DELAY)
): StateFlow<TrackedDeviceList> {
data class MyKey(val duration: Duration) : Key<SessionDeviceTracker>("trackDevices")
// Note: We return the `stateFlow` outside the cache `getOrPut` method, since
// `getOrPut` may create multiple instances in case
// of concurrent first cache hit.
return this.cache.getOrPut(MyKey(retryDelay)) {
SessionDeviceTracker(this, retryDelay)
}.stateFlow
}
/**
* A [list][DeviceList] of [DeviceInfo] as collected by the [state flow][StateFlow]
* returned by [AdbSession.trackDevices].
*/
class TrackedDeviceList(
/**
* Connection id, unique to each underlying [AdbHostServices.trackDevices] connection.
* [DeviceInfo.serialNumber] may not uniquely identify a device if their connection ids
* are not the same.
*/
val connectionId: Int,
/**
* The [list][DeviceList] of [DeviceInfo], the list is always empty if the underlying
* connection to ADB failed.
*/
val devices: DeviceList,
/**
* The last [Throwable] collected when the underlying ADB connection failed, or `null`
* if the underlying ADB connection is active.
*/
val throwable: Throwable?
) {
override fun toString(): String {
return "${this::class.simpleName}: connectionId=$connectionId, " +
"device count=${devices.size}, throwable=$throwable"
}
}
/**
* Returns `true` if this [TrackedDeviceList] instance has been produced by
* [AdbSession.trackDevices] due to a connection failure.
*/
val TrackedDeviceList.isTrackerDisconnected: Boolean
get() {
return this.devices === TrackerDisconnected.instance
}
/**
* Returns `true` if this [TrackedDeviceList] instance is the initial value produced by
* the [StateFlow] returned by [AdbSession.trackDevices].
*/
val TrackedDeviceList.isTrackerConnecting: Boolean
get() {
return this.devices === TrackerConnecting.instance
}
/**
* Returns a flow of [DeviceInfo] that tracks changes to a given [device][DeviceSelector],
* typically changes to the [DeviceInfo.deviceState] value.
*
* The flow terminates when the device is no longer connected or when the ADB
* connection is terminated.
*/
fun AdbSession.trackDeviceInfo(device: DeviceSelector): Flow<DeviceInfo> {
return DeviceInfoTracker(this, device).createFlow()
}
/**
* Returns a [CoroutineScope] that can be used to run coroutines that should be cancelled
* when the given [device] is disconnected (or ADB connection is terminated).
*
* The returned scope is also cancelled when the [AdbSession] is [closed][AdbSession.close].
*
* The returned [CoroutineScope] uses a [CoroutineContext] with a [SupervisorJob] tied to
* the [device] lifecycle and a [AdbSessionHost.ioDispatcher].
*
* @see [trackDeviceInfo]
*/
fun AdbSession.createDeviceScope(device: DeviceSelector): CoroutineScope {
val session = this
val parentJob = session.scope.coroutineContext.job
return CoroutineScope(SupervisorJob(parentJob) + session.host.ioDispatcher).also { deviceScope ->
// Launch a coroutine that track the device state and cancel the scope
// when that device disconnects.
deviceScope.launch {
try {
// Use device info tracking flow
session.trackDeviceInfo(device).collect()
} finally {
val msg = "Device $device has been disconnected, cancelling job"
adbLogger(session.host).debug { msg }
deviceScope.cancel(CancellationException(msg))
}
}
}
}
/**
* Returns the [ConnectedDevicesTracker] associated to this session
*/
val AdbSession.connectedDevicesTracker: ConnectedDevicesTracker
get() {
return this.cache.getOrPut(ConnectedDevicesManagerKey) {
ConnectedDevicesTrackerImpl(this)
}
}
/**
* The [Key] used to identify the [ConnectedDevicesTracker] in [AdbSession.cache].
*/
private object ConnectedDevicesManagerKey : Key<ConnectedDevicesTracker>(ConnectedDevicesTracker::class.java.simpleName)
/**
* Provides a way to store data in a device cache by device selector.
*
* @throws ClosedSessionException if this [AdbSession] has been [closed][AdbSession.close].
*/
val AdbSession.deviceCacheProvider: DeviceCacheProvider
get() {
return this.cache.getOrPut(DeviceCacheProviderKey) {
ConnectedDevicesDeviceCacheProvider(this)
}
}
/**
* The [Key] used to identify the [DeviceCacheProvider] in [AdbSession.cache].
*/
private object DeviceCacheProviderKey : Key<DeviceCacheProvider>(DeviceCacheProvider::class.java.simpleName)