blob: 2631c56769363bd6e12bcb5d56db366612147eae [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.android.connecteddevice.api
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Handler
import android.os.IBinder
import android.os.IInterface
import android.os.Looper
import android.os.ParcelUuid
import androidx.annotation.VisibleForTesting
import com.google.android.companionprotos.Query
import com.google.android.companionprotos.QueryResponse
import com.google.android.companionprotos.SystemQuery
import com.google.android.companionprotos.SystemQueryType
import com.google.android.connecteddevice.api.SafeConnector.AppNameCallback
import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_BIND_FEATURE_COORDINATOR
import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_QUERY_API_VERSION
import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
import com.google.android.connecteddevice.api.external.ISafeBinderVersion
import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator
import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
import com.google.android.connecteddevice.util.ByteUtils
import com.google.android.connecteddevice.util.Logger
import com.google.android.connecteddevice.util.SafeLog
import com.google.protobuf.ByteString
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
/**
* Class for establishing and maintaining a connection between external features and the companion
* device platform.
*
* @param context [Context] of the hosting process.
* @param featureId Identifier of the feature that is running this connector.
* @param callback Callback associated with this connector.
* @param minSupportedVersion External feature's minimum supported Companion API version.
*/
class FeatureConnector(
private val context: Context,
override val featureId: ParcelUuid,
private val minSupportedVersion: Int = 0
) : SafeConnector {
@VisibleForTesting internal var bindAttempts = 0
private val lock = ReentrantLock()
private val retryHandler = Handler(Looper.getMainLooper())
private val loggerId = Logger.getLogger().loggerId
private val waitingForConnection = AtomicBoolean(true)
private val queryIdGenerator = QueryIdGenerator()
override lateinit var callback: SafeConnector.Callback
override val isConnected: Boolean
get() = coordinatorProxy != null && !waitingForConnection.get()
override val connectedDevices: List<String>
get() = coordinatorProxy?.getConnectedDevices() ?: emptyList()
/** [CompanionApiProxy] acting as a wrapper for feature coordinator calls */
@VisibleForTesting internal var coordinatorProxy: CompanionApiProxy? = null
@VisibleForTesting internal var platformVersion: Int? = null
@VisibleForTesting
internal val versionCheckConnection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
if (service !is ISafeBinderVersion) {
logd("Unexpected binder received from platform. Aborting.")
callback.onFailedToConnect()
return
} else if (minSupportedVersion > service.getVersion()) {
loge("Incompatible client and platform versions detected. Aborting.")
callback.onApiNotSupported()
return
} else {
platformVersion = service.getVersion()
bindAttempts = 0
bindToService(ACTION_BIND_FEATURE_COORDINATOR, featureCoordinatorConnection)
}
}
override fun onServiceDisconnected(name: ComponentName) {
this@FeatureConnector.onServiceDisconnected()
}
override fun onNullBinding(name: ComponentName) {
logd("Connecting to a companion platform of version 0.")
platformVersion = 0
bindAttempts = 0
bindToService(ACTION_BIND_FEATURE_COORDINATOR, featureCoordinatorConnection)
}
override fun onBindingDied(name: ComponentName?) {
this@FeatureConnector.onBindingDied()
}
}
@VisibleForTesting
internal val featureCoordinatorConnection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
logd("Feature coordinator binder connected.")
val platformVersion = platformVersion
if (platformVersion == null) {
loge("Incompatible companion platform version. Aborting.")
return
}
if (!this@FeatureConnector::callback.isInitialized) {
loge("FeatureConnector callback was not initialized. Aborting.")
return
}
coordinatorProxy =
when {
platformVersion > 0 ->
SafeApiProxy(
ISafeFeatureCoordinator.Stub.asInterface(service),
featureId,
callback,
loggerId,
platformVersion
)
platformVersion == 0 ->
LegacyApiProxy(
IFeatureCoordinator.Stub.asInterface(service),
featureId,
callback,
loggerId,
platformVersion
)
else -> {
loge("Incompatible companion platform version. Aborting.")
return
}
}
logd("FeatureCoordinator initialized.")
waitingForConnection.set(false)
callback.onConnected()
}
override fun onServiceDisconnected(name: ComponentName) {
this@FeatureConnector.onServiceDisconnected()
}
override fun onNullBinding(name: ComponentName) {
this@FeatureConnector.onNullBinding()
}
override fun onBindingDied(name: ComponentName?) {
this@FeatureConnector.onBindingDied()
}
}
override fun connect(callback: SafeConnector.Callback) {
logd("Initiating connection to companion platform and initializing callback.")
this.callback = callback
bindToService(ACTION_QUERY_API_VERSION, versionCheckConnection)
}
private fun bindToService(action: String, serviceConnection: ServiceConnection) {
val intent = resolveIntent(action)
if (intent == null) {
loge("No services found supporting companion device. Aborting.")
callback.onFailedToConnect()
return
}
val success = context.bindService(intent, serviceConnection, /* flag= */ 0)
if (success) {
logd("Successfully started binding with ${intent.action}.")
return
}
bindAttempts++
if (bindAttempts > MAX_BIND_ATTEMPTS) {
loge("Failed to bind to service after $bindAttempts attempts. Aborting.")
waitingForConnection.set(false)
callback.onFailedToConnect()
return
}
retryHandler.postDelayed(
{
logw("Unable to bind to service with action ${intent.action}. Trying again.")
bindToService(action, serviceConnection)
},
BIND_RETRY_DURATION.toMillis()
)
}
private fun resolveIntent(action: String): Intent? {
val packageManager = context.packageManager
val intent = Intent(action)
val services = packageManager.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY)
if (services.isEmpty()) {
logw("There are no services supporting the $action action installed on this device.")
return null
}
logd("Found ${services.size} service(s) supporting $action. Choosing the first one.")
val service = services[0]
return intent.apply {
component = ComponentName(service.serviceInfo.packageName, service.serviceInfo.name)
}
}
override fun disconnect() {
logd("Disconnecting from the companion platform.")
coordinatorProxy?.cleanUp()
coordinatorProxy = null
unbindFromService()
callback.onDisconnected()
}
private fun unbindFromService() {
retryHandler.removeCallbacksAndMessages(/* token= */ null)
try {
context.unbindService(featureCoordinatorConnection)
} catch (e: IllegalArgumentException) {
logw("Attempted to unbind an already unbound service.")
}
waitingForConnection.set(false)
}
private fun onServiceDisconnected() {
logd("Service has disconnected. Cleaning up.")
disconnect()
}
private fun onNullBinding() {
loge("Received a null binding for FeatureCoordinator. Unbinding service.")
unbindFromService()
callback.onFailedToConnect()
}
private fun onBindingDied() {
logw("FeatureCoordinator binding died. Unbinding service.")
unbindFromService()
callback.onDisconnected()
}
override fun sendMessage(deviceId: String, message: ByteArray) {
val coordinatorProxy = coordinatorProxy
if (coordinatorProxy == null) {
loge("Unable to send message with a null feature coordinator.")
callback.onMessageFailedToSend(deviceId, message, isTransient = true)
return
}
if (!connectedDevices.contains(deviceId)) {
loge("No matching device found with id $deviceId when trying to send secure message.")
callback.onMessageFailedToSend(deviceId, message, isTransient = false)
return
}
if (!coordinatorProxy.sendMessage(deviceId, message)) {
loge("Feature coordinator failed to send message.")
callback.onMessageFailedToSend(deviceId, message, isTransient = false)
}
}
override fun sendQuery(
deviceId: String,
request: ByteArray,
parameters: ByteArray?,
queryCallback: QueryCallback
) {
val coordinatorProxy = coordinatorProxy
if (coordinatorProxy == null) {
loge("Unable to send query with a null feature coordinator.")
queryCallback.onQueryFailedToSend(isTransient = false)
return
}
if (!connectedDevices.contains(deviceId)) {
loge("No matching device found with id $deviceId when trying to send a query.")
queryCallback.onQueryFailedToSend(isTransient = false)
return
}
val id = queryIdGenerator.next()
val builder =
Query.newBuilder()
.setId(id)
.setSender(ByteString.copyFrom(ByteUtils.uuidToBytes(featureId.uuid)))
.setRequest(ByteString.copyFrom(request))
if (parameters != null) {
builder.parameters = ByteString.copyFrom(parameters)
}
logd("Sending secure query with id $id.")
if (!coordinatorProxy.sendMessage(deviceId, builder.build().toByteArray())) {
loge("Error while sending query.")
queryCallback.onQueryFailedToSend(isTransient = false)
}
coordinatorProxy.queryCallbacks[id] = queryCallback
}
override fun respondToQuery(
deviceId: String,
queryId: Int,
success: Boolean,
response: ByteArray?
) {
val coordinatorProxy = coordinatorProxy
if (coordinatorProxy == null) {
loge("Unable to respond to query with a null feature coordinator.")
return
}
val recipientId = coordinatorProxy.queryResponseRecipients.remove(queryId)
if (recipientId == null) {
loge("Unable to send response to unrecognized query $queryId.")
return
}
val builder = QueryResponse.newBuilder().setQueryId(queryId).setSuccess(success)
if (response != null) {
builder.response = ByteString.copyFrom(response)
}
val queryResponse = builder.build()
logd("Sending response to query $queryId to $recipientId.")
if (!coordinatorProxy.sendMessage(deviceId, queryResponse.toByteArray())) {
loge("Feature coordinator failed to send query.")
callback.onMessageFailedToSend(deviceId, queryResponse.toByteArray(), isTransient = false)
}
}
override fun retrieveCompanionApplicationName(
deviceId: String,
appNameCallback: AppNameCallback
) {
val systemQuery = SystemQuery.newBuilder().setType(SystemQueryType.APP_NAME).build()
sendQuery(
deviceId,
systemQuery.toByteArray(),
parameters = null,
object : QueryCallback {
override fun onSuccess(response: ByteArray?) {
if (response == null || response.isEmpty()) {
loge("Received a null or empty response for the application name.")
appNameCallback.onError()
return
}
val appName = String(response, StandardCharsets.UTF_8)
logd("Received successful app name query response of $appName.")
appNameCallback.onNameReceived(appName)
}
override fun onError(response: ByteArray?) {
loge("Received an error response when querying for application name.")
appNameCallback.onError()
}
override fun onQueryFailedToSend(isTransient: Boolean) {
loge("Failed to send the query for the application name.")
appNameCallback.onError()
}
}
)
}
override fun retrieveAssociatedDevices(listener: IOnAssociatedDevicesRetrievedListener) {
retrieveAssociatedDevicesInternal(listener)
}
override fun retrieveAssociatedDevices(listener: ISafeOnAssociatedDevicesRetrievedListener) {
retrieveAssociatedDevicesInternal(listener)
}
private fun retrieveAssociatedDevicesInternal(listener: IInterface) {
val coordinatorProxy = coordinatorProxy
if (coordinatorProxy == null) {
loge("Unable to retrieve associated devices with a null feature coordinator.")
return
}
if (!coordinatorProxy.retrieveAssociatedDevices(listener)) {
logw("Failed to retrieve associated devices.")
}
}
private fun logd(message: String) {
SafeLog.logd(TAG, "$message [Feature ID: $featureId]")
}
private fun logw(message: String) {
SafeLog.logw(TAG, "$message [Feature ID: $featureId]")
}
private fun loge(message: String, e: Exception? = null) {
SafeLog.loge(TAG, "$message [Feature ID: $featureId]", e)
}
companion object {
private const val TAG = "FeatureConnector"
private val BIND_RETRY_DURATION = Duration.ofSeconds(1)
@VisibleForTesting internal const val MAX_BIND_ATTEMPTS = 3
// TODO(alwa) Move this (and QueryIdGenerator in CompanionConnector) to its own internal class.
/** A generator of unique IDs for queries. */
private class QueryIdGenerator {
private val messageId = AtomicInteger(0)
fun next(): Int {
val current = messageId.getAndIncrement()
messageId.compareAndSet(Int.MAX_VALUE, 0)
return current
}
}
}
}