blob: 79046db04d1b07761b50aadf988d3b5d4277c93e [file] [log] [blame]
/*
* Copyright (C) 2020 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.server.translation;
import static android.view.translation.TranslationManager.EXTRA_CAPABILITIES;
import static android.view.translation.TranslationManager.STATUS_SYNC_CALL_FAIL;
import static android.view.translation.UiTranslationManager.EXTRA_PACKAGE_NAME;
import static android.view.translation.UiTranslationManager.EXTRA_SOURCE_LOCALE;
import static android.view.translation.UiTranslationManager.EXTRA_STATE;
import static android.view.translation.UiTranslationManager.EXTRA_TARGET_LOCALE;
import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_FINISHED;
import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_PAUSED;
import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_RESUMED;
import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_STARTED;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.IRemoteCallback;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.os.ResultReceiver;
import android.service.translation.TranslationService;
import android.service.translation.TranslationServiceInfo;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import android.util.Slog;
import android.view.autofill.AutofillId;
import android.view.inputmethod.InputMethodInfo;
import android.view.translation.ITranslationServiceCallback;
import android.view.translation.TranslationCapability;
import android.view.translation.TranslationContext;
import android.view.translation.TranslationSpec;
import android.view.translation.UiTranslationController;
import android.view.translation.UiTranslationManager.UiTranslationState;
import android.view.translation.UiTranslationSpec;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.os.IResultReceiver;
import com.android.internal.os.TransferPipe;
import com.android.server.LocalServices;
import com.android.server.infra.AbstractPerUserSystemService;
import com.android.server.inputmethod.InputMethodManagerInternal;
import com.android.server.wm.ActivityTaskManagerInternal;
import com.android.server.wm.ActivityTaskManagerInternal.ActivityTokens;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.ref.WeakReference;
import java.util.List;
final class TranslationManagerServiceImpl extends
AbstractPerUserSystemService<TranslationManagerServiceImpl, TranslationManagerService>
implements IBinder.DeathRecipient {
private static final String TAG = "TranslationManagerServiceImpl";
@SuppressLint("IsLoggableTagLength")
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
@GuardedBy("mLock")
@Nullable
private RemoteTranslationService mRemoteTranslationService;
@GuardedBy("mLock")
@Nullable
private ServiceInfo mRemoteTranslationServiceInfo;
@GuardedBy("mLock")
private TranslationServiceInfo mTranslationServiceInfo;
@GuardedBy("mLock")
private WeakReference<ActivityTokens> mLastActivityTokens;
private final ActivityTaskManagerInternal mActivityTaskManagerInternal;
private final TranslationServiceRemoteCallback mRemoteServiceCallback =
new TranslationServiceRemoteCallback();
private final RemoteCallbackList<IRemoteCallback> mTranslationCapabilityCallbacks =
new RemoteCallbackList<>();
private final ArraySet<IBinder> mWaitingFinishedCallbackActivities = new ArraySet<>();
/**
* Key is translated activity token, value is the specification and state for the translation.
*/
@GuardedBy("mLock")
private final ArrayMap<IBinder, ActiveTranslation> mActiveTranslations = new ArrayMap<>();
protected TranslationManagerServiceImpl(
@NonNull TranslationManagerService master,
@NonNull Object lock, int userId, boolean disabled) {
super(master, lock, userId);
updateRemoteServiceLocked();
mActivityTaskManagerInternal = LocalServices.getService(ActivityTaskManagerInternal.class);
}
@GuardedBy("mLock")
@Override // from PerUserSystemService
protected ServiceInfo newServiceInfoLocked(@NonNull ComponentName serviceComponent)
throws PackageManager.NameNotFoundException {
mTranslationServiceInfo = new TranslationServiceInfo(getContext(),
serviceComponent, isTemporaryServiceSetLocked(), mUserId);
mRemoteTranslationServiceInfo = mTranslationServiceInfo.getServiceInfo();
return mTranslationServiceInfo.getServiceInfo();
}
@GuardedBy("mLock")
@Override // from PerUserSystemService
protected boolean updateLocked(boolean disabled) {
final boolean enabledChanged = super.updateLocked(disabled);
updateRemoteServiceLocked();
return enabledChanged;
}
/**
* Updates the reference to the remote service.
*/
@GuardedBy("mLock")
private void updateRemoteServiceLocked() {
if (mRemoteTranslationService != null) {
if (mMaster.debug) Slog.d(TAG, "updateRemoteService(): destroying old remote service");
mRemoteTranslationService.unbind();
mRemoteTranslationService = null;
}
}
@GuardedBy("mLock")
@Nullable
private RemoteTranslationService ensureRemoteServiceLocked() {
if (mRemoteTranslationService == null) {
final String serviceName = getComponentNameLocked();
if (serviceName == null) {
if (mMaster.verbose) {
Slog.v(TAG, "ensureRemoteServiceLocked(): no service component name.");
}
return null;
}
final ComponentName serviceComponent = ComponentName.unflattenFromString(serviceName);
boolean isServiceAvailableForUser;
final long identity = Binder.clearCallingIdentity();
try {
isServiceAvailableForUser = isServiceAvailableForUser(serviceComponent);
if (mMaster.verbose) {
Slog.v(TAG, "ensureRemoteServiceLocked(): isServiceAvailableForUser="
+ isServiceAvailableForUser);
}
} finally {
Binder.restoreCallingIdentity(identity);
}
if (!isServiceAvailableForUser) {
Slog.w(TAG, "ensureRemoteServiceLocked(): " + serviceComponent
+ " is not available,");
return null;
}
mRemoteTranslationService = new RemoteTranslationService(getContext(), serviceComponent,
mUserId, /* isInstantAllowed= */ false, mRemoteServiceCallback);
}
return mRemoteTranslationService;
}
private boolean isServiceAvailableForUser(ComponentName serviceComponent) {
Intent intent = new Intent(TranslationService.SERVICE_INTERFACE)
.setComponent(serviceComponent);
final ResolveInfo resolveInfo = getContext().getPackageManager().resolveServiceAsUser(
intent, PackageManager.GET_SERVICES | PackageManager.GET_META_DATA, mUserId);
return resolveInfo != null && resolveInfo.serviceInfo != null;
}
@GuardedBy("mLock")
void onTranslationCapabilitiesRequestLocked(@TranslationSpec.DataFormat int sourceFormat,
@TranslationSpec.DataFormat int destFormat,
@NonNull ResultReceiver resultReceiver) {
final RemoteTranslationService remoteService = ensureRemoteServiceLocked();
if (remoteService != null) {
remoteService.onTranslationCapabilitiesRequest(sourceFormat, destFormat,
resultReceiver);
} else {
Slog.v(TAG, "onTranslationCapabilitiesRequestLocked(): no remote service.");
resultReceiver.send(STATUS_SYNC_CALL_FAIL, null);
}
}
public void registerTranslationCapabilityCallback(IRemoteCallback callback, int sourceUid) {
mTranslationCapabilityCallbacks.register(callback, sourceUid);
ensureRemoteServiceLocked();
}
public void unregisterTranslationCapabilityCallback(IRemoteCallback callback) {
mTranslationCapabilityCallbacks.unregister(callback);
}
@GuardedBy("mLock")
void onSessionCreatedLocked(@NonNull TranslationContext translationContext, int sessionId,
IResultReceiver resultReceiver) throws RemoteException {
final RemoteTranslationService remoteService = ensureRemoteServiceLocked();
if (remoteService != null) {
remoteService.onSessionCreated(translationContext, sessionId, resultReceiver);
} else {
Slog.v(TAG, "onSessionCreatedLocked(): no remote service.");
resultReceiver.send(STATUS_SYNC_CALL_FAIL, null);
}
}
private int getAppUidByComponentName(Context context, ComponentName componentName, int userId) {
int translatedAppUid = -1;
try {
if (componentName != null) {
translatedAppUid = context.getPackageManager().getApplicationInfoAsUser(
componentName.getPackageName(), 0, userId).uid;
}
} catch (PackageManager.NameNotFoundException e) {
Slog.d(TAG, "Cannot find packageManager for" + componentName);
}
return translatedAppUid;
}
@GuardedBy("mLock")
public void onTranslationFinishedLocked(boolean activityDestroyed, IBinder token,
ComponentName componentName) {
final int translatedAppUid =
getAppUidByComponentName(getContext(), componentName, getUserId());
final String packageName = componentName.getPackageName();
// In the Activity destroyed case, we only call onTranslationFinished() in
// non-finishTranslation() state. If there is a finishTranslation() call by apps, we
// should remove the waiting callback to avoid invoking callbacks twice.
if (activityDestroyed || mWaitingFinishedCallbackActivities.contains(token)) {
invokeCallbacks(STATE_UI_TRANSLATION_FINISHED,
/* sourceSpec= */ null, /* targetSpec= */ null,
packageName, translatedAppUid);
mWaitingFinishedCallbackActivities.remove(token);
mActiveTranslations.remove(token);
}
}
@GuardedBy("mLock")
public void updateUiTranslationStateLocked(@UiTranslationState int state,
TranslationSpec sourceSpec, TranslationSpec targetSpec, List<AutofillId> viewIds,
IBinder token, int taskId, UiTranslationSpec uiTranslationSpec) {
// If the app starts a new Activity in the same task then the finish or pause API
// is called, the operation doesn't work if we only check task top Activity. The top
// Activity is the new Activity, the original Activity is paused in the same task.
// To make sure the operation still work, we use the token to find the target Activity in
// this task, not the top Activity only.
//
// Note: getAttachedNonFinishingActivityForTask() takes the shareable activity token. We
// call this method so that we can get the regular activity token below.
ActivityTokens candidateActivityTokens =
mActivityTaskManagerInternal.getAttachedNonFinishingActivityForTask(taskId, token);
if (candidateActivityTokens == null) {
Slog.w(TAG, "Unknown activity or it was finished to query for update "
+ "translation state for token=" + token + " taskId=" + taskId + " for "
+ "state= " + state);
return;
}
mLastActivityTokens = new WeakReference<>(candidateActivityTokens);
if (state == STATE_UI_TRANSLATION_FINISHED) {
mWaitingFinishedCallbackActivities.add(token);
}
IBinder activityToken = candidateActivityTokens.getActivityToken();
try {
candidateActivityTokens.getApplicationThread().updateUiTranslationState(
activityToken, state, sourceSpec, targetSpec,
viewIds, uiTranslationSpec);
} catch (RemoteException e) {
Slog.w(TAG, "Update UiTranslationState fail: " + e);
}
ComponentName componentName = mActivityTaskManagerInternal.getActivityName(activityToken);
int translatedAppUid =
getAppUidByComponentName(getContext(), componentName, getUserId());
String packageName = componentName.getPackageName();
invokeCallbacksIfNecessaryLocked(state, sourceSpec, targetSpec, packageName, token,
translatedAppUid);
updateActiveTranslationsLocked(state, sourceSpec, targetSpec, packageName, token,
translatedAppUid);
}
@GuardedBy("mLock")
private void updateActiveTranslationsLocked(int state, TranslationSpec sourceSpec,
TranslationSpec targetSpec, String packageName, IBinder shareableActivityToken,
int translatedAppUid) {
// We keep track of active translations and their state so that we can:
// 1. Trigger callbacks that are registered after translation has started.
// See registerUiTranslationStateCallbackLocked().
// 2. NOT trigger callbacks when the state didn't change.
// See invokeCallbacksIfNecessaryLocked().
ActiveTranslation activeTranslation = mActiveTranslations.get(shareableActivityToken);
switch (state) {
case STATE_UI_TRANSLATION_STARTED: {
if (activeTranslation == null) {
try {
shareableActivityToken.linkToDeath(this, /* flags= */ 0);
} catch (RemoteException e) {
Slog.w(TAG, "Failed to call linkToDeath for translated app with uid="
+ translatedAppUid + "; activity is already dead", e);
// Apps with registered callbacks were just notified that translation
// started. We should let them know translation is finished too.
invokeCallbacks(STATE_UI_TRANSLATION_FINISHED, sourceSpec, targetSpec,
packageName, translatedAppUid);
return;
}
mActiveTranslations.put(shareableActivityToken,
new ActiveTranslation(sourceSpec, targetSpec, translatedAppUid,
packageName));
}
break;
}
case STATE_UI_TRANSLATION_PAUSED: {
if (activeTranslation != null) {
activeTranslation.isPaused = true;
}
break;
}
case STATE_UI_TRANSLATION_RESUMED: {
if (activeTranslation != null) {
activeTranslation.isPaused = false;
}
break;
}
case STATE_UI_TRANSLATION_FINISHED: {
if (activeTranslation != null) {
mActiveTranslations.remove(shareableActivityToken);
}
break;
}
}
if (DEBUG) {
Slog.d(TAG,
"Updating to translation state=" + state + " for app with uid="
+ translatedAppUid + " packageName=" + packageName);
}
}
@GuardedBy("mLock")
private void invokeCallbacksIfNecessaryLocked(int state, TranslationSpec sourceSpec,
TranslationSpec targetSpec, String packageName, IBinder shareableActivityToken,
int translatedAppUid) {
boolean shouldInvokeCallbacks = true;
int stateForCallbackInvocation = state;
ActiveTranslation activeTranslation = mActiveTranslations.get(shareableActivityToken);
if (activeTranslation == null) {
if (state != STATE_UI_TRANSLATION_STARTED) {
shouldInvokeCallbacks = false;
Slog.w(TAG,
"Updating to translation state=" + state + " for app with uid="
+ translatedAppUid + " packageName=" + packageName
+ " but no active translation was found for it");
}
} else {
switch (state) {
case STATE_UI_TRANSLATION_STARTED: {
boolean specsAreIdentical = activeTranslation.sourceSpec.getLocale().equals(
sourceSpec.getLocale())
&& activeTranslation.targetSpec.getLocale().equals(
targetSpec.getLocale());
if (specsAreIdentical) {
if (activeTranslation.isPaused) {
// Ideally UiTranslationManager.resumeTranslation() should be first
// used to resume translation, but for the purposes of invoking the
// callback, we want to call onResumed() instead of onStarted(). This
// way there can only be one call to onStarted() for the lifetime of
// a translated activity and this will simplify the number of states
// apps have to handle.
stateForCallbackInvocation = STATE_UI_TRANSLATION_RESUMED;
} else {
// Don't invoke callbacks if the state or specs didn't change. For a
// given activity, startTranslation() will be called every time there
// are new views to be translated, but we don't need to repeatedly
// notify apps about it.
shouldInvokeCallbacks = false;
}
}
break;
}
case STATE_UI_TRANSLATION_PAUSED: {
if (activeTranslation.isPaused) {
// Don't invoke callbacks if the state didn't change.
shouldInvokeCallbacks = false;
}
break;
}
case STATE_UI_TRANSLATION_RESUMED: {
if (!activeTranslation.isPaused) {
// Don't invoke callbacks if the state didn't change. Either
// resumeTranslation() was called consecutive times, or right after
// startTranslation(). The latter case shouldn't happen normally, so we
// don't want apps to have to handle that particular transition.
shouldInvokeCallbacks = false;
}
break;
}
case STATE_UI_TRANSLATION_FINISHED: {
// Note: Here finishTranslation() was called but we don't want to invoke
// onFinished() on the callbacks. They will be invoked when
// UiTranslationManager.onTranslationFinished() is called (see
// onTranslationFinishedLocked()).
shouldInvokeCallbacks = false;
break;
}
}
}
if (shouldInvokeCallbacks) {
invokeCallbacks(stateForCallbackInvocation, sourceSpec, targetSpec, packageName,
translatedAppUid);
}
}
@GuardedBy("mLock")
public void dumpLocked(String prefix, FileDescriptor fd, PrintWriter pw) {
if (mLastActivityTokens != null) {
ActivityTokens activityTokens = mLastActivityTokens.get();
if (activityTokens == null) {
return;
}
try (TransferPipe tp = new TransferPipe()) {
activityTokens.getApplicationThread().dumpActivity(tp.getWriteFd(),
activityTokens.getActivityToken(), prefix,
new String[]{
Activity.DUMP_ARG_DUMP_DUMPABLE,
UiTranslationController.DUMPABLE_NAME
});
tp.go(fd);
} catch (IOException e) {
pw.println(prefix + "Failure while dumping the activity: " + e);
} catch (RemoteException e) {
pw.println(prefix + "Got a RemoteException while dumping the activity");
}
} else {
pw.print(prefix);
pw.println("No requested UiTranslation Activity.");
}
final int waitingFinishCallbackSize = mWaitingFinishedCallbackActivities.size();
if (waitingFinishCallbackSize > 0) {
pw.print(prefix);
pw.print("number waiting finish callback activities: ");
pw.println(waitingFinishCallbackSize);
for (IBinder activityToken : mWaitingFinishedCallbackActivities) {
pw.print(prefix);
pw.print("shareableActivityToken: ");
pw.println(activityToken);
}
}
}
private void invokeCallbacks(
int state, TranslationSpec sourceSpec, TranslationSpec targetSpec, String packageName,
int translatedAppUid) {
Bundle result = createResultForCallback(state, sourceSpec, targetSpec, packageName);
int registeredCallbackCount = mCallbacks.getRegisteredCallbackCount();
if (DEBUG) {
Slog.d(TAG, "Invoking " + registeredCallbackCount + " callbacks for translation state="
+ state + " for app with uid=" + translatedAppUid
+ " packageName=" + packageName);
}
if (registeredCallbackCount == 0) {
return;
}
List<InputMethodInfo> enabledInputMethods = getEnabledInputMethods();
mCallbacks.broadcast((callback, uid) -> {
invokeCallback((int) uid, translatedAppUid, callback, result, enabledInputMethods);
});
}
private List<InputMethodInfo> getEnabledInputMethods() {
return LocalServices.getService(InputMethodManagerInternal.class)
.getEnabledInputMethodListAsUser(mUserId);
}
private Bundle createResultForCallback(
int state, TranslationSpec sourceSpec, TranslationSpec targetSpec, String packageName) {
Bundle result = new Bundle();
result.putInt(EXTRA_STATE, state);
// TODO(177500482): Store the locale pair so it can be sent for RESUME events.
if (sourceSpec != null) {
result.putSerializable(EXTRA_SOURCE_LOCALE, sourceSpec.getLocale());
result.putSerializable(EXTRA_TARGET_LOCALE, targetSpec.getLocale());
}
result.putString(EXTRA_PACKAGE_NAME, packageName);
return result;
}
private void invokeCallback(
int callbackSourceUid, int translatedAppUid, IRemoteCallback callback,
Bundle result, List<InputMethodInfo> enabledInputMethods) {
if (callbackSourceUid == translatedAppUid) {
// Invoke callback for the application being translated.
try {
callback.sendResult(result);
} catch (RemoteException e) {
Slog.w(TAG, "Failed to invoke UiTranslationStateCallback: " + e);
}
return;
}
// TODO(177500482): Only support the *current* Input Method.
// Code here is non-optimal since it's temporary..
boolean isIme = false;
for (InputMethodInfo inputMethod : enabledInputMethods) {
if (callbackSourceUid == inputMethod.getServiceInfo().applicationInfo.uid) {
isIme = true;
break;
}
}
if (!isIme) {
return;
}
try {
callback.sendResult(result);
} catch (RemoteException e) {
Slog.w(TAG, "Failed to invoke UiTranslationStateCallback: " + e);
}
}
@GuardedBy("mLock")
public void registerUiTranslationStateCallbackLocked(IRemoteCallback callback, int sourceUid) {
mCallbacks.register(callback, sourceUid);
int numActiveTranslations = mActiveTranslations.size();
Slog.i(TAG, "New registered callback for sourceUid=" + sourceUid + " with currently "
+ numActiveTranslations + " active translations");
if (numActiveTranslations == 0) {
return;
}
// Trigger the callback for already active translations.
List<InputMethodInfo> enabledInputMethods = getEnabledInputMethods();
for (int i = 0; i < mActiveTranslations.size(); i++) {
ActiveTranslation activeTranslation = mActiveTranslations.valueAt(i);
int translatedAppUid = activeTranslation.translatedAppUid;
String packageName = activeTranslation.packageName;
if (DEBUG) {
Slog.d(TAG, "Triggering callback for sourceUid=" + sourceUid
+ " for translated app with uid=" + translatedAppUid
+ "packageName=" + packageName + " isPaused=" + activeTranslation.isPaused);
}
Bundle startedResult = createResultForCallback(STATE_UI_TRANSLATION_STARTED,
activeTranslation.sourceSpec, activeTranslation.targetSpec,
packageName);
invokeCallback(sourceUid, translatedAppUid, callback, startedResult,
enabledInputMethods);
if (activeTranslation.isPaused) {
// Also send event so callback owners know that translation was started then paused.
Bundle pausedResult = createResultForCallback(STATE_UI_TRANSLATION_PAUSED,
activeTranslation.sourceSpec, activeTranslation.targetSpec,
packageName);
invokeCallback(sourceUid, translatedAppUid, callback, pausedResult,
enabledInputMethods);
}
}
}
public void unregisterUiTranslationStateCallback(IRemoteCallback callback) {
mCallbacks.unregister(callback);
}
private final RemoteCallbackList<IRemoteCallback> mCallbacks = new RemoteCallbackList<>();
public ComponentName getServiceSettingsActivityLocked() {
if (mTranslationServiceInfo == null) {
return null;
}
final String activityName = mTranslationServiceInfo.getSettingsActivity();
if (activityName == null) {
return null;
}
final String packageName = mTranslationServiceInfo.getServiceInfo().packageName;
return new ComponentName(packageName, activityName);
}
private void notifyClientsTranslationCapability(TranslationCapability capability) {
final Bundle res = new Bundle();
res.putParcelable(EXTRA_CAPABILITIES, capability);
mTranslationCapabilityCallbacks.broadcast((callback, uid) -> {
try {
callback.sendResult(res);
} catch (RemoteException e) {
Slog.w(TAG, "Failed to invoke UiTranslationStateCallback: " + e);
}
});
}
private final class TranslationServiceRemoteCallback extends
ITranslationServiceCallback.Stub {
@Override
public void updateTranslationCapability(TranslationCapability capability) {
if (capability == null) {
Slog.wtf(TAG, "received a null TranslationCapability from TranslationService.");
return;
}
notifyClientsTranslationCapability(capability);
}
}
private static final class ActiveTranslation {
public final TranslationSpec sourceSpec;
public final TranslationSpec targetSpec;
public final String packageName;
public final int translatedAppUid;
public boolean isPaused = false;
private ActiveTranslation(TranslationSpec sourceSpec, TranslationSpec targetSpec,
int translatedAppUid, String packageName) {
this.sourceSpec = sourceSpec;
this.targetSpec = targetSpec;
this.translatedAppUid = translatedAppUid;
this.packageName = packageName;
}
}
@Override
public void binderDied() {
// Don't need to implement this with binderDied(IBinder) implemented.
}
@Override
public void binderDied(IBinder who) {
synchronized (mLock) {
mWaitingFinishedCallbackActivities.remove(who);
ActiveTranslation activeTranslation = mActiveTranslations.remove(who);
if (activeTranslation != null) {
// Let apps with registered callbacks know about the activity's death.
invokeCallbacks(STATE_UI_TRANSLATION_FINISHED, activeTranslation.sourceSpec,
activeTranslation.targetSpec, activeTranslation.packageName,
activeTranslation.translatedAppUid);
}
}
}
}