blob: f4aaf5d4fc2f0cca51a3b6b5f4af28399eb0e3b4 [file] [log] [blame]
/*
* Copyright 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 androidx.appactions.interaction.service;
import android.util.Log;
import android.util.SizeF;
import android.widget.RemoteViews;
import android.widget.RemoteViewsService.RemoteViewsFactory;
import androidx.annotation.NonNull;
import androidx.appactions.interaction.capabilities.core.Capability;
import androidx.appactions.interaction.capabilities.core.HostProperties;
import androidx.appactions.interaction.capabilities.core.LibInfo;
import androidx.appactions.interaction.capabilities.core.impl.ArgumentsWrapper;
import androidx.appactions.interaction.capabilities.core.impl.CapabilitySession;
import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal;
import androidx.appactions.interaction.capabilities.core.impl.concurrent.FutureCallback;
import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures;
import androidx.appactions.interaction.capabilities.core.impl.utils.CapabilityLogger;
import androidx.appactions.interaction.capabilities.core.impl.utils.LoggerInternal;
import androidx.appactions.interaction.proto.AppActionsContext;
import androidx.appactions.interaction.proto.FulfillmentRequest;
import androidx.appactions.interaction.proto.FulfillmentResponse;
import androidx.appactions.interaction.proto.GroundingRequest;
import androidx.appactions.interaction.proto.GroundingResponse;
import androidx.appactions.interaction.proto.Version;
import androidx.appactions.interaction.service.proto.AppInteractionServiceGrpc.AppInteractionServiceImplBase;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.CollectionRequest;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.CollectionResponse;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.RemoteViewsInfo;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.Request;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.Response;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.StartSessionRequest;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.StartSessionResponse;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.Status.Code;
import androidx.appactions.interaction.service.proto.AppInteractionServiceProto.UiUpdate;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import io.grpc.Status;
import io.grpc.StatusException;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
/**
* Implementation of {@link AppInteractionServiceImplBase} generated from the GRPC proto file. This
* class delegates the requests to the appropriate capability session.
*/
final class AppInteractionServiceGrpcImpl extends AppInteractionServiceImplBase {
private static final String TAG = "ActionsServiceGrpcImpl";
static final String ERROR_NO_SESSION = "Session not available";
static final String ERROR_NO_FULFILLMENT_REQUEST = "Fulfillment request missing";
static final String ERROR_NO_ACTION_CAPABILITY = "Capability was not found";
static final String ERROR_SESSION_ENDED = "Session already ended";
private static final String ERROR_NO_COLLECTION_SUPPORT =
"Session doesn't support collection view";
private static final String ERROR_NO_UI = "No UI set";
private static final String ERROR_MULTIPLE_UI_TYPES =
"Multiple UI types used in current session";
final AppInteractionService mAppInteractionService;
List<Capability> mRegisteredCapabilities = new ArrayList<>();
static {
LoggerInternal.setLogger(
new CapabilityLogger() {
@Override
public void log(
@NonNull LogLevel logLevel,
@NonNull String logTag,
@NonNull String message) {
switch (logLevel) {
case ERROR:
Log.e(logTag, message);
break;
case WARN:
Log.w(logTag, message);
break;
case INFO:
Log.i(logTag, message);
break;
}
}
@Override
public void log(
@NonNull LogLevel logLevel,
@NonNull String logTag,
@NonNull String message,
@NonNull Throwable throwable) {
switch (logLevel) {
case ERROR:
Log.e(logTag, message, throwable);
break;
case WARN:
Log.w(logTag, message, throwable);
break;
case INFO:
Log.i(logTag, message, throwable);
break;
}
}
});
}
AppInteractionServiceGrpcImpl(AppInteractionService mAppInteractionService) {
this.mAppInteractionService = mAppInteractionService;
}
@Override
public StreamObserver<StartSessionRequest> startUpSession(
StreamObserver<StartSessionResponse> responseObserver) {
return new StartSessionRequestObserver(responseObserver);
}
private final class StartSessionRequestObserver implements StreamObserver<StartSessionRequest> {
private final StreamObserver<StartSessionResponse> mStartSessionResponseObserver;
// Every AppInteractionService connection is defined by this streaming RPC connection.
// There should only be one session tied to each gRPC impl instance.
private String mCurrentSessionId = null;
StartSessionRequestObserver(StreamObserver<StartSessionResponse> responseObserver) {
this.mStartSessionResponseObserver = responseObserver;
}
@Override
public void onNext(StartSessionRequest request) {
if (mCurrentSessionId != null) {
return;
}
mCurrentSessionId = request.getSessionIdentifier();
if (mRegisteredCapabilities.isEmpty()) {
mRegisteredCapabilities = mAppInteractionService.getRegisteredCapabilities();
}
Optional<Capability> targetCapability =
mRegisteredCapabilities.stream()
.filter(cap -> request.getIdentifier().equals(cap.getId()))
.findFirst();
if (!targetCapability.isPresent()) {
mStartSessionResponseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(
ERROR_NO_ACTION_CAPABILITY)));
return;
}
HostProperties hostProperties =
new HostProperties.Builder()
.setMaxHostSizeDp(new SizeF(
request.getHostProperties().getHostViewHeightDp(),
request.getHostProperties().getHostViewWidthDp()))
.build();
CapabilitySession session = targetCapability.get().createSession(
mCurrentSessionId, hostProperties);
SessionManager.INSTANCE.putSession(mCurrentSessionId, session);
mStartSessionResponseObserver.onNext(StartSessionResponse.getDefaultInstance());
}
@Override
public void onError(Throwable t) {
synchronized (mStartSessionResponseObserver) {
mStartSessionResponseObserver.onError(t);
}
if (mCurrentSessionId != null) {
destroySession(mCurrentSessionId);
}
mCurrentSessionId = null;
}
@Override
public void onCompleted() {
synchronized (mStartSessionResponseObserver) {
mStartSessionResponseObserver.onCompleted();
}
if (mCurrentSessionId != null) {
destroySession(mCurrentSessionId);
}
mCurrentSessionId = null;
}
}
@Override
public void sendRequestFulfillment(Request request, StreamObserver<Response> responseObserver) {
if (request.getFulfillmentRequest().getFulfillmentsList().isEmpty()) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(
ERROR_NO_FULFILLMENT_REQUEST)));
return;
}
FulfillmentRequest.Fulfillment selectedFulfillment =
request.getFulfillmentRequest().getFulfillments(0);
Optional<Capability> capability =
mRegisteredCapabilities.stream()
.filter(cap -> selectedFulfillment.getIdentifier().equals(cap.getId()))
.findFirst();
if (!capability.isPresent()) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(
ERROR_NO_ACTION_CAPABILITY)));
return;
}
String sessionId = request.getSessionIdentifier();
CapabilitySession currentSession = SessionManager.INSTANCE.getSession(sessionId);
if (currentSession == null) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_NO_SESSION)));
return;
}
if (currentSession.getStatus() == CapabilitySession.Status.COMPLETED
|| currentSession.getStatus() == CapabilitySession.Status.DESTROYED) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_SESSION_ENDED)));
return;
}
Futures.addCallback(
executeFulfillmentRequest(currentSession, selectedFulfillment),
new FutureCallback<FulfillmentResponse>() {
@Override
public void onSuccess(FulfillmentResponse fulfillmentResponse) {
Response.Builder responseBuilder =
convertFulfillmentResponse(fulfillmentResponse, capability.get())
.toBuilder();
UiCache uiCache = UiSessions.INSTANCE.getUiCacheOrNull(sessionId);
if (uiCache != null && uiCache.hasUnreadUiResponse()) {
responseBuilder.setUiUpdate(UiUpdate.getDefaultInstance());
if (!uiCache.getCachedChangedViewIds().isEmpty()) {
responseBuilder.setCollectionUpdate(
AppInteractionServiceProto.CollectionUpdate.newBuilder()
.addAllViewIds(uiCache.getCachedChangedViewIds()));
}
uiCache.resetUnreadUiResponse();
}
respondAndComplete(responseBuilder.build(), responseObserver);
}
@Override
public void onFailure(@NonNull Throwable t) {
Throwable outputThrowable;
if (t instanceof CapabilityExecutionException) {
outputThrowable =
convertToGrpcException((CapabilityExecutionException) t);
} else if (t instanceof StatusRuntimeException
|| t instanceof StatusException) {
outputThrowable = t;
} else {
outputThrowable =
new StatusRuntimeException(
Status.INTERNAL.withDescription(
t.getMessage()).withCause(t));
}
responseObserver.onError(outputThrowable);
// Assistant will terminate the connection, which will reach
// startUpSession.onError(t) / onCompleted()
}
},
Runnable::run);
}
@Override
public void requestUi(
AppInteractionServiceProto.UiRequest req,
StreamObserver<AppInteractionServiceProto.UiResponse> responseObserver) {
String sessionId = req.getSessionIdentifier();
CapabilitySession currentSession = SessionManager.INSTANCE
.getSession(sessionId);
if (currentSession == null) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_NO_SESSION)));
return;
}
if (currentSession.getStatus() == CapabilitySession.Status.COMPLETED) {
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_SESSION_ENDED)));
return;
}
UiCache uiCache = UiSessions.INSTANCE.getUiCacheOrNull(sessionId);
if (uiCache == null) {
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(Status.INTERNAL.withDescription(ERROR_NO_UI)));
return;
}
TileLayoutInternal tileLayout = uiCache.getCachedTileLayout();
SizeF remoteViewsSize = uiCache.getCachedRemoteViewsSize();
RemoteViews remoteViews = uiCache.getCachedRemoteViews();
if (tileLayout != null && remoteViews != null) {
// TODO(b/272379825): Decide if this is really an invalid state.
// both types of UI are present, this is a misused of API. We will treat it as error.
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(
Status.INTERNAL.withDescription(ERROR_MULTIPLE_UI_TYPES)));
return;
}
if (tileLayout != null) {
respondAndComplete(
AppInteractionServiceProto.UiResponse.newBuilder()
.setTileLayout(tileLayout.toProto())
.build(),
responseObserver);
return;
}
if (remoteViews != null && remoteViewsSize != null) {
RemoteViewsOverMetadataInterceptor.setRemoteViews(remoteViews);
respondAndComplete(
AppInteractionServiceProto.UiResponse.newBuilder()
.setRemoteViewsInfo(
RemoteViewsInfo.newBuilder()
.setWidthDp(remoteViewsSize.getWidth())
.setHeightDp(remoteViewsSize.getHeight()))
.build(),
responseObserver);
return;
}
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(Status.INTERNAL.withDescription(ERROR_NO_UI)));
}
@Override
public void requestCollection(
CollectionRequest req, StreamObserver<CollectionResponse> responseObserver) {
String sessionId = req.getSessionIdentifier();
CapabilitySession currentSession = SessionManager.INSTANCE
.getSession(sessionId);
if (currentSession == null) {
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_NO_SESSION)));
return;
}
if (currentSession.getStatus() == CapabilitySession.Status.COMPLETED) {
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription(ERROR_SESSION_ENDED)));
return;
}
UiCache uiCache = UiSessions.INSTANCE.getUiCacheOrNull(sessionId);
if (uiCache == null) {
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(Status.INTERNAL.withDescription(ERROR_NO_UI)));
return;
}
RemoteViewsFactory factory = uiCache.onGetViewFactoryInternal(req.getViewId());
if (factory == null) {
destroySession(req.getSessionIdentifier());
responseObserver.onError(
new StatusRuntimeException(
Status.UNIMPLEMENTED.withDescription(ERROR_NO_COLLECTION_SUPPORT)));
return;
}
switch (req.getRequestDataCase()) {
case ON_DESTROY: {
requestCollectionOnDestroy(factory, responseObserver);
break;
}
case GET_COUNT: {
requestCollectionGetCount(factory, responseObserver);
break;
}
case GET_VIEW_AT: {
requestCollectionGetViewAt(factory, responseObserver,
req.getGetViewAt().getPosition());
break;
}
case GET_LOADING_VIEW: {
requestCollectionGetLoadingView(factory, responseObserver);
break;
}
case GET_VIEW_TYPE_COUNT: {
requestCollectionGetViewTypeCount(factory, responseObserver);
break;
}
case GET_ITEM_ID: {
requestCollectionGetItemId(factory, responseObserver,
req.getGetItemId().getPosition());
break;
}
case HAS_STABLE_IDS: {
requestCollectionHasStableIds(factory, responseObserver);
break;
}
default: {
// ignore it
Log.d(TAG, "received CollectionRequest with unknown RequestData case.");
responseObserver.onCompleted();
break;
}
}
}
@Override
public void requestGrounding(
GroundingRequest request, StreamObserver<GroundingResponse> responseObserver) {
// TODO(b/268265068): Implement grounding API
}
private void requestCollectionOnDestroy(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer) {
factory.onDestroy();
respondAndComplete(CollectionResponse.getDefaultInstance(), observer);
}
private void requestCollectionGetCount(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer) {
respondAndComplete(
CollectionResponse.newBuilder()
.setGetCount(CollectionResponse.GetCount.newBuilder()
.setCount(factory.getCount()))
.build(),
observer);
}
private void requestCollectionGetViewAt(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer, int position) {
RemoteViews view = factory.getViewAt(position);
if (view != null) {
RemoteViewsOverMetadataInterceptor.setRemoteViews(view);
}
respondAndComplete(CollectionResponse.getDefaultInstance(), observer);
}
private void requestCollectionGetLoadingView(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer) {
RemoteViews loadingView = factory.getLoadingView();
if (loadingView != null) {
RemoteViewsOverMetadataInterceptor.setRemoteViews(loadingView);
}
respondAndComplete(CollectionResponse.getDefaultInstance(), observer);
}
private void requestCollectionGetViewTypeCount(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer) {
respondAndComplete(
CollectionResponse.newBuilder()
.setGetViewTypeCount(
CollectionResponse.GetViewTypeCount.newBuilder()
.setViewTypeCount(factory.getViewTypeCount()))
.build(),
observer);
}
private void requestCollectionGetItemId(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer, int position) {
respondAndComplete(
CollectionResponse.newBuilder()
.setGetItemId(
CollectionResponse.GetItemId.newBuilder()
.setItemId(factory.getItemId(position)))
.build(),
observer);
}
private void requestCollectionHasStableIds(
RemoteViewsFactory factory, StreamObserver<CollectionResponse> observer) {
respondAndComplete(
CollectionResponse.newBuilder()
.setHasStableIds(
CollectionResponse.HasStableIds.newBuilder()
.setHasStableIds(factory.hasStableIds()))
.build(),
observer);
}
@NonNull
private Version convertToAppActionsContextVersion(@NonNull LibInfo.Version libInfoVersion) {
Version.Builder builder = Version.newBuilder()
.setMajor(libInfoVersion.getMajor())
.setMinor(libInfoVersion.getMinor())
.setPatch(libInfoVersion.getPatch());
if (libInfoVersion.getPreReleaseId() != null) {
builder.setPrereleaseId(libInfoVersion.getPreReleaseId());
}
return builder.build();
}
void destroySession(@NonNull String sessionId) {
CapabilitySession session = SessionManager.INSTANCE.getSession(sessionId);
if (session != null) {
session.destroy();
}
SessionManager.INSTANCE.removeSession(sessionId);
}
@NonNull
StatusRuntimeException convertToGrpcException(CapabilityExecutionException e) {
if (e.getErrorStatus() == ErrorStatusInternal.TIMEOUT) {
return new StatusRuntimeException(
Status.DEADLINE_EXCEEDED.withDescription(e.getMessage()).withCause(e));
}
return new StatusRuntimeException(
Status.INTERNAL.withDescription(e.getMessage()).withCause(e));
}
@NonNull
Response convertFulfillmentResponse(
@NonNull FulfillmentResponse fulfillmentResponse,
@NonNull Capability capability) {
AppActionsContext.AppAction appAction = capability.getAppAction();
boolean isDialogSession = appAction.getTaskInfo().getSupportsPartialFulfillment();
Version version = convertToAppActionsContextVersion(
new LibInfo(mAppInteractionService.getApplicationContext()).getVersion());
Response.Builder responseBuilder =
// TODO(b/269638788): Add DialogState to the Response proto.
Response.newBuilder()
.setFulfillmentResponse(fulfillmentResponse)
.setAppActionsContext(
AppActionsContext.newBuilder()
.addActions(appAction)
.setVersion(version)
.build());
if (!isDialogSession) {
responseBuilder.setEndingStatus(
AppInteractionServiceProto.Status.newBuilder()
.setStatusCode(Code.COMPLETE)
.build());
}
return responseBuilder.build();
}
@NonNull
ListenableFuture<FulfillmentResponse> executeFulfillmentRequest(
@NonNull CapabilitySession session,
@NonNull FulfillmentRequest.Fulfillment fulfillmentRequest) {
return CallbackToFutureAdapter.getFuture(
completer -> {
session.execute(
ArgumentsWrapper.create(fulfillmentRequest),
new CapabilityCallback(completer));
return "executing action capability";
});
}
static <T> void respondAndComplete(T response, StreamObserver<T> responseObserver) {
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}