Merge changes from topics "medialibraryservice2", "mediabrowser2"

* changes:
  MediaSession2: Initial commit of MediaLibraryService2
  MediaSession2: Initial commit of MediaBrowser2
This commit is contained in:
Jaewan Kim
2018-01-24 04:41:09 +00:00
committed by Android (Google) Code Review
12 changed files with 365 additions and 72 deletions

View File

@@ -46,6 +46,11 @@ interface IMediaSession2 {
PlaybackState getPlaybackState();
//////////////////////////////////////////////////////////////////////////////////////////////
// Get library service specific
//////////////////////////////////////////////////////////////////////////////////////////////
oneway void getBrowserRoot(IMediaSession2Callback callback, in Bundle rootHints);
//////////////////////////////////////////////////////////////////////////////////////////////
// Callbacks -- remove them
//////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -44,4 +44,9 @@ oneway interface IMediaSession2Callback {
// Follow-up TODO: Add similar functions to the session.
// TODO(jaewan): Is term 'accepted/rejected' correct? For permission, 'grant' is used.
void onConnectionChanged(IMediaSession2 sessionBinder, in Bundle commandGroup);
//////////////////////////////////////////////////////////////////////////////////////////////
// Browser sepcific
//////////////////////////////////////////////////////////////////////////////////////////////
void onGetRootResult(in Bundle rootHints, String rootMediaId, in Bundle rootExtra);
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2018 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 android.media;
import android.annotation.Nullable;
import android.content.Context;
import android.media.update.ApiLoader;
import android.media.update.MediaBrowser2Provider;
import android.os.Bundle;
import java.util.concurrent.Executor;
/**
* Browses media content offered by a {@link MediaLibraryService2}.
* @hide
*/
public class MediaBrowser2 extends MediaController2 {
// Equals to the ((MediaBrowser2Provider) getProvider())
private final MediaBrowser2Provider mProvider;
/**
* Callback to listen events from {@link MediaLibraryService2}.
*/
public abstract static class BrowserCallback extends MediaController2.ControllerCallback {
/**
* Called with the result of {@link #getBrowserRoot(Bundle)}.
* <p>
* {@code rootMediaId} and {@code rootExtra} can be {@code null} if the browser root isn't
* available.
*
* @param rootHints rootHints that you previously requested.
* @param rootMediaId media id of the browser root. Can be {@code null}
* @param rootExtra extra of the browser root. Can be {@code null}
*/
public abstract void onGetRootResult(Bundle rootHints, @Nullable String rootMediaId,
@Nullable Bundle rootExtra);
}
public MediaBrowser2(Context context, SessionToken token, BrowserCallback callback,
Executor executor) {
super(context, token, callback, executor);
mProvider = (MediaBrowser2Provider) getProvider();
}
@Override
MediaBrowser2Provider createProvider(Context context, SessionToken token,
ControllerCallback callback, Executor executor) {
return ApiLoader.getProvider(context)
.createMediaBrowser2(this, context, token, (BrowserCallback) callback, executor);
}
public void getBrowserRoot(Bundle rootHints) {
mProvider.getBrowserRoot_impl(rootHints);
}
}

View File

@@ -104,7 +104,13 @@ public class MediaController2 extends MediaPlayerBase {
// session whose session binder is only valid while it's active.
// prevent a controller from reusable after the
// session is released and recreated.
mProvider = ApiLoader.getProvider(context)
mProvider = createProvider(context, token, callback, executor);
}
MediaController2Provider createProvider(@NonNull Context context,
@NonNull SessionToken token, @NonNull ControllerCallback callback,
@NonNull Executor executor) {
return ApiLoader.getProvider(context)
.createMediaController2(this, context, token, callback, executor);
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2018 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 android.media;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.media.MediaSession2.BuilderBase;
import android.media.MediaSession2.ControllerInfo;
import android.media.update.ApiLoader;
import android.media.update.MediaSessionService2Provider;
import android.os.Bundle;
import android.service.media.MediaBrowserService.BrowserRoot;
/**
* Base class for media library services.
* <p>
* Media library services enable applications to browse media content provided by an application
* and ask the application to start playing it. They may also be used to control content that
* is already playing by way of a {@link MediaSession2}.
* <p>
* To extend this class, adding followings directly to your {@code AndroidManifest.xml}.
* <pre>
* &lt;service android:name="component_name_of_your_implementation" &gt;
* &lt;intent-filter&gt;
* &lt;action android:name="android.media.MediaLibraryService2" /&gt;
* &lt;/intent-filter&gt;
* &lt;/service&gt;</pre>
* <p>
* A {@link MediaLibraryService2} is extension of {@link MediaSessionService2}. IDs shouldn't
* be shared between the {@link MediaSessionService2} and {@link MediaSession2}. By
* default, an empty string will be used for ID of the service. If you want to specify an ID,
* declare metadata in the manifest as follows.
* @hide
*/
// TODO(jaewan): Unhide
public abstract class MediaLibraryService2 extends MediaSessionService2 {
/**
* This is the interface name that a service implementing a session service should say that it
* support -- that is, this is the action it uses for its intent filter.
*/
public static final String SERVICE_INTERFACE = "android.media.MediaLibraryService2";
/**
* Session for the media library service.
*/
public class MediaLibrarySession extends MediaSession2 {
MediaLibrarySession(Context context, MediaPlayerBase player, String id,
SessionCallback callback) {
super(context, player, id, callback);
}
// TODO(jaewan): Place public methods here.
}
public static abstract class MediaLibrarySessionCallback extends MediaSession2.SessionCallback {
/**
* Called to get the root information for browsing by a particular client.
* <p>
* The implementation should verify that the client package has permission
* to access browse media information before returning the root id; it
* should return null if the client is not allowed to access this
* information.
*
* @param controllerInfo information of the controller requesting access to browse media.
* @param rootHints An optional bundle of service-specific arguments to send
* to the media browser service when connecting and retrieving the
* root id for browsing, or null if none. The contents of this
* bundle may affect the information returned when browsing.
* @return The {@link BrowserRoot} for accessing this app's content or null.
* @see BrowserRoot#EXTRA_RECENT
* @see BrowserRoot#EXTRA_OFFLINE
* @see BrowserRoot#EXTRA_SUGGESTED
*/
public abstract @Nullable BrowserRoot onGetRoot(
@NonNull ControllerInfo controllerInfo, @Nullable Bundle rootHints);
}
/**
* Builder for {@link MediaLibrarySession}.
*/
// TODO(jaewan): Move this to updatable.
public class MediaLibrarySessionBuilder
extends BuilderBase<MediaLibrarySessionBuilder, MediaLibrarySessionCallback> {
public MediaLibrarySessionBuilder(
@NonNull Context context, @NonNull MediaPlayerBase player,
@NonNull MediaLibrarySessionCallback callback) {
super(context, player);
setSessionCallback(callback);
}
@Override
public MediaLibrarySessionBuilder setSessionCallback(
@NonNull MediaLibrarySessionCallback callback) {
if (callback == null) {
throw new IllegalArgumentException("MediaLibrarySessionCallback cannot be null");
}
return super.setSessionCallback(callback);
}
@Override
public MediaLibrarySession build() throws IllegalStateException {
return new MediaLibrarySession(mContext, mPlayer, mId, mCallback);
}
}
@Override
MediaSessionService2Provider createProvider() {
return ApiLoader.getProvider(this).createMediaLibraryService2(this);
}
/**
* Called when another app requested to start this service.
* <p>
* Library service will accept or reject the connection with the
* {@link MediaLibrarySessionCallback} in the created session.
* <p>
* Service wouldn't run if {@code null} is returned or session's ID doesn't match with the
* expected ID that you've specified through the AndroidManifest.xml.
* <p>
* This method will be called on the main thread.
*
* @param sessionId session id written in the AndroidManifest.xml.
* @return a new browser session
* @see MediaLibrarySessionBuilder
* @see #getSession()
* @throws RuntimeException if returned session is invalid
*/
@Override
public @NonNull abstract MediaLibrarySession onCreateSession(String sessionId);
}

View File

@@ -16,7 +16,6 @@
package android.media;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
@@ -29,12 +28,9 @@ import android.media.update.MediaSession2Provider.ControllerInfoProvider;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcelable;
import android.os.Process;
import android.text.TextUtils;
import android.util.ArraySet;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
@@ -55,20 +51,17 @@ import java.util.List;
* instead. With it, your playback can be revived even after you've finished playback. See
* {@link MediaSessionService2} for details.
* <p>
* A session can be obtained by {@link #getInstance(Context, Handler)}. The owner of the session may
* pass its session token to other processes to allow them to create a {@link MediaController2}
* to interact with the session.
* A session can be obtained by {@link Builder}. The owner of the session may pass its session token
* to other processes to allow them to create a {@link MediaController2} to interact with the
* session.
* <p>
* To receive transport control commands, an underlying media player must be set with
* {@link #setPlayer(MediaPlayerBase)}. Commands will be sent to the underlying player directly
* on the thread that had been specified by {@link #getInstance(Context, Handler)}.
* When a session receive transport control commands, the session sends the commands directly to
* the the underlying media player set by {@link Builder} or {@link #setPlayer(MediaPlayerBase)}.
* <p>
* When an app is finished performing playback it must call
* {@link #setPlayer(MediaPlayerBase)} with {@code null} to clean up the session and notify any
* controllers. It's developers responsibility of cleaning the session and releasing resources.
* When an app is finished performing playback it must call {@link #close()} to clean up the session
* and notify any controllers.
* <p>
* MediaSession2 objects should be used on the handler's thread that is initially given by
* {@link #getInstance(Context, Handler)}.
* {@link MediaSession2} objects should be used on the thread on the looper.
*
* @see MediaSessionService2
* @hide
@@ -81,7 +74,7 @@ import java.util.List;
// TODO(jaewan): Should we make APIs for MediaSessionService2 public? It's helpful for
// developers that doesn't want to override from Browser, but user may not use this
// correctly.
public final class MediaSession2 extends MediaPlayerBase {
public class MediaSession2 extends MediaPlayerBase {
private final MediaSession2Provider mProvider;
// Note: Do not define IntDef because subclass can add more command code on top of these.
@@ -321,19 +314,16 @@ public final class MediaSession2 extends MediaPlayerBase {
};
/**
* Builder for {@link MediaSession2}.
* <p>
* Any incoming event from the {@link MediaController2} will be handled on the thread
* that created session with the {@link Builder#build()}.
* Base builder class for MediaSession2 and its subclass.
*
* @hide
*/
// TODO(jaewan): Move this to updatable
// TODO(jaewan): Add setRatingType()
// TODO(jaewan): Add setSessionActivity()
public final static class Builder {
private final Context mContext;
private final MediaPlayerBase mPlayer;
private String mId;
private SessionCallback mCallback;
static abstract class BuilderBase
<T extends MediaSession2.BuilderBase<T, C>, C extends SessionCallback> {
final Context mContext;
final MediaPlayerBase mPlayer;
String mId;
C mCallback;
/**
* Constructor.
@@ -343,7 +333,8 @@ public final class MediaSession2 extends MediaPlayerBase {
* @throws IllegalArgumentException if any parameter is null, or the player is a
* {@link MediaSession2} or {@link MediaController2}.
*/
public Builder(@NonNull Context context, @NonNull MediaPlayerBase player) {
// TODO(jaewan): Also need executor
public BuilderBase(@NonNull Context context, @NonNull MediaPlayerBase player) {
if (context == null) {
throw new IllegalArgumentException("context shouldn't be null");
}
@@ -370,12 +361,12 @@ public final class MediaSession2 extends MediaPlayerBase {
* @throws IllegalArgumentException if id is {@code null}
* @return
*/
public Builder setId(@NonNull String id) {
public T setId(@NonNull String id) {
if (id == null) {
throw new IllegalArgumentException("id shouldn't be null");
}
mId = id;
return this;
return (T) this;
}
/**
@@ -384,9 +375,9 @@ public final class MediaSession2 extends MediaPlayerBase {
* @param callback session callback.
* @return
*/
public Builder setSessionCallback(@Nullable SessionCallback callback) {
public T setSessionCallback(@Nullable C callback) {
mCallback = callback;
return this;
return (T) this;
}
/**
@@ -396,6 +387,24 @@ public final class MediaSession2 extends MediaPlayerBase {
* @throws IllegalStateException if the session with the same id is already exists for the
* package.
*/
public abstract MediaSession2 build() throws IllegalStateException;
}
/**
* Builder for {@link MediaSession2}.
* <p>
* Any incoming event from the {@link MediaController2} will be handled on the thread
* that created session with the {@link Builder#build()}.
*/
// TODO(jaewan): Move this to updatable
// TODO(jaewan): Add setRatingType()
// TODO(jaewan): Add setSessionActivity()
public static final class Builder extends BuilderBase<Builder, SessionCallback> {
public Builder(Context context, @NonNull MediaPlayerBase player) {
super(context, player);
}
@Override
public MediaSession2 build() throws IllegalStateException {
if (mCallback == null) {
mCallback = new SessionCallback();
@@ -492,7 +501,7 @@ public final class MediaSession2 extends MediaPlayerBase {
* framework had to add heuristics to figure out if an app is
* @hide
*/
private MediaSession2(Context context, MediaPlayerBase player, String id,
MediaSession2(Context context, MediaPlayerBase player, String id,
SessionCallback callback) {
super();
mProvider = ApiLoader.getProvider(context)

View File

@@ -29,7 +29,7 @@ import android.media.update.MediaSessionService2Provider;
import android.os.IBinder;
/**
* Service version of the {@link MediaSession2}.
* Base class for media session services, which is the service version of the {@link MediaSession2}.
* <p>
* It's highly recommended for an app to use this instead of {@link MediaSession2} if it wants
* to keep media playback in the background.
@@ -43,11 +43,11 @@ import android.os.IBinder;
* </ul>
* For example, user's voice command can start playback of your app even when it's not running.
* <p>
* To use this class, adding followings directly to your {@code AndroidManifest.xml}.
* To extend this class, adding followings directly to your {@code AndroidManifest.xml}.
* <pre>
* &lt;service android:name="component_name_of_your_implementation" &gt;
* &lt;intent-filter&gt;
* &lt;action android:name="android.media.session.MediaSessionService2" /&gt;
* &lt;action android:name="android.media.MediaSessionService2" /&gt;
* &lt;/intent-filter&gt;
* &lt;/service&gt;</pre>
* <p>
@@ -58,7 +58,7 @@ import android.os.IBinder;
* <pre>
* &lt;service android:name="component_name_of_your_implementation" &gt;
* &lt;intent-filter&gt;
* &lt;action android:name="android.media.session.MediaSessionService2" /&gt;
* &lt;action android:name="android.media.MediaSessionService2" /&gt;
* &lt;/intent-filter&gt;
* &lt;meta-data android:name="android.media.session"
* android:value="session_id"/&gt;
@@ -120,8 +120,7 @@ public abstract class MediaSessionService2 extends Service {
* This is the interface name that a service implementing a session service should say that it
* support -- that is, this is the action it uses for its intent filter.
*/
public static final String SERVICE_INTERFACE =
"android.media.session.MediaSessionService2";
public static final String SERVICE_INTERFACE = "android.media.MediaSessionService2";
/**
* Name under which a MediaSessionService2 component publishes information about itself.
@@ -129,21 +128,13 @@ public abstract class MediaSessionService2 extends Service {
*/
public static final String SERVICE_META_DATA = "android.media.session";
/**
* Default notification channel ID used by {@link #onUpdateNotification(PlaybackState)}.
*
*/
public static final String DEFAULT_MEDIA_NOTIFICATION_CHANNEL_ID = "media_session_service";
/**
* Default notification channel ID used by {@link #onUpdateNotification(PlaybackState)}.
*
*/
public static final int DEFAULT_MEDIA_NOTIFICATION_ID = 1001;
public MediaSessionService2() {
super();
mProvider = ApiLoader.getProvider(this).createMediaSessionService2(this);
mProvider = createProvider();
}
MediaSessionService2Provider createProvider() {
return ApiLoader.getProvider(this).createMediaSessionService2(this);
}
/**
@@ -168,32 +159,24 @@ public abstract class MediaSessionService2 extends Service {
* Service wouldn't run if {@code null} is returned or session's ID doesn't match with the
* expected ID that you've specified through the AndroidManifest.xml.
* <p>
* This method will be call on the main thread.
* This method will be called on the main thread.
*
* @param sessionId session id written in the AndroidManifest.xml.
* @return a new session
* @see MediaSession2.Builder
* @see #getSession()
*/
// TODO(jaewan): Replace this with onCreateSession(). Its sesssion callback will replace
// this abstract method.
// TODO(jaewan): Should we also include/documents notification listener access?
// TODO(jaewan): Is term accepted/rejected correct? For permission, granted is more common.
// TODO(jaewan): Return ConnectResult() that encapsulate supported action and player.
public @NonNull abstract MediaSession2 onCreateSession(String sessionId);
/**
* Called when the playback state of this session is changed, and notification needs update.
* <p>
* Default media style notification will be shown if you don't override this or return
* {@code null}. Override this method to show your own notification UI.
* Override this method to show your own notification UI.
* <p>
* With the notification returned here, the service become foreground service when the playback
* is started. It becomes background service after the playback is stopped.
*
* @param state playback state
* @return a {@link MediaNotification}. If it's {@code null}, default notification will be shown
* instead.
* @return a {@link MediaNotification}. If it's {@code null}, notification wouldn't be shown.
*/
// TODO(jaewan): Also add metadata
public MediaNotification onUpdateNotification(PlaybackState state) {

View File

@@ -42,12 +42,13 @@ import java.lang.annotation.RetentionPolicy;
// TODO(jaewan): Find better name for this (SessionToken or Session2Token)
public final class SessionToken {
@Retention(RetentionPolicy.SOURCE)
@IntDef(value = {TYPE_SESSION, TYPE_SESSION_SERVICE})
@IntDef(value = {TYPE_SESSION, TYPE_SESSION_SERVICE, TYPE_LIBRARY_SERVICE})
public @interface TokenType {
}
public static final int TYPE_SESSION = 0;
public static final int TYPE_SESSION_SERVICE = 1;
public static final int TYPE_LIBRARY_SERVICE = 2;
private static final String KEY_TYPE = "android.media.token.type";
private static final String KEY_PACKAGE_NAME = "android.media.token.package_name";
@@ -73,6 +74,7 @@ public final class SessionToken {
* @hide
*/
// TODO(jaewan): UID is also needed.
// TODO(jaewan): Unhide
public SessionToken(@TokenType int type, @NonNull String packageName, @NonNull String id,
@Nullable String serviceName, @Nullable IMediaSession2 sessionBinder) {
// TODO(jaewan): Add sanity check.

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2018 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 android.media.update;
import android.os.Bundle;
/**
* @hide
*/
public interface MediaBrowser2Provider extends MediaController2Provider {
void getBrowserRoot_impl(Bundle rootHints);
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2018 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 android.media.update;
/**
* @hide
*/
public interface MediaLibraryService2Provider extends MediaSessionService2Provider {
// Nothing new for now
}

View File

@@ -19,7 +19,11 @@ package android.media.update;
import android.annotation.Nullable;
import android.content.Context;
import android.media.IMediaSession2Callback;
import android.media.MediaBrowser2;
import android.media.MediaBrowser2.BrowserCallback;
import android.media.MediaController2;
import android.media.MediaController2.ControllerCallback;
import android.media.MediaLibraryService2;
import android.media.MediaPlayerBase;
import android.media.MediaSession2;
import android.media.MediaSessionService2;
@@ -53,7 +57,12 @@ public interface StaticProvider {
String packageName, IMediaSession2Callback callback);
MediaController2Provider createMediaController2(
MediaController2 instance, Context context, SessionToken token,
MediaController2.ControllerCallback callback, Executor executor);
ControllerCallback callback, Executor executor);
MediaBrowser2Provider createMediaBrowser2(
MediaBrowser2 instance, Context context, SessionToken token,
BrowserCallback callback, Executor executor);
MediaSessionService2Provider createMediaSessionService2(
MediaSessionService2 instance);
MediaSessionService2Provider createMediaLibraryService2(
MediaLibraryService2 instance);
}

View File

@@ -38,6 +38,7 @@ import android.media.AudioSystem;
import android.media.IAudioService;
import android.media.IMediaSession2;
import android.media.IRemoteVolumeController;
import android.media.MediaLibraryService2;
import android.media.MediaSessionService2;
import android.media.SessionToken;
import android.media.session.IActiveSessionsListener;
@@ -445,9 +446,18 @@ public class MediaSessionService extends SystemService implements Monitor {
}
// TODO(jaewan): Query per users.
List<ResolveInfo> services = getContext().getPackageManager().queryIntentServices(
new Intent(MediaSessionService2.SERVICE_INTERFACE),
PackageManager.GET_META_DATA);
List<ResolveInfo> services = new ArrayList<>();
// If multiple actions are declared for a service, browser gets higher priority.
List<ResolveInfo> libraryServices = getContext().getPackageManager().queryIntentServices(
new Intent(MediaLibraryService2.SERVICE_INTERFACE), PackageManager.GET_META_DATA);
if (libraryServices != null) {
services.addAll(libraryServices);
}
List<ResolveInfo> sessionServices = getContext().getPackageManager().queryIntentServices(
new Intent(MediaSessionService2.SERVICE_INTERFACE), PackageManager.GET_META_DATA);
if (sessionServices != null) {
services.addAll(sessionServices);
}
synchronized (mLock) {
mSessions.clear();
if (services == null) {
@@ -470,10 +480,11 @@ public class MediaSessionService extends SystemService implements Monitor {
+ " the same ID=" + id + ". Ignoring "
+ serviceInfo.packageName + "/" + serviceInfo.name);
} else {
int type = (libraryServices.contains(services.get(i)))
? SessionToken.TYPE_LIBRARY_SERVICE : SessionToken.TYPE_SESSION_SERVICE;
MediaSessionService2Record record =
new MediaSessionService2Record(getContext(), mSessionDestroyedListener,
SessionToken.TYPE_SESSION_SERVICE,
serviceInfo.packageName, serviceInfo.name, id);
type, serviceInfo.packageName, serviceInfo.name, id);
mSessions.add(record);
}
}