/*
* Copyright 2019 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.media3.session;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
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.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.ResultReceiver;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.text.TextUtils;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media3.common.Bundleable;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.util.UnstableApi;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
/**
* A token that represents an ongoing {@link MediaSession} or a service ({@link
* MediaSessionService}, {@link MediaLibraryService}, or {@link MediaBrowserServiceCompat}). If it
* represents a service, it may not be ongoing.
*
* <p>This may be passed to apps by the session owner to allow them to create a {@link
* MediaController} or a {@link MediaBrowser} to communicate with the session.
*
* <p>It can also be obtained by {@link #getAllServiceTokens(Context)}.
*/
// New version of MediaSession.Token for following reasons
// - Stop implementing Parcelable for updatable support
// - Represent session and library service (formerly browser service) in one class.
// Previously MediaSession.Token was for session and ComponentName was for service.
// This helps controller apps to keep target of dispatching media key events in uniform way.
// For details about the reason, see following. (Android O+)
// android.media.session.MediaSessionManager.Callback#onAddressedPlayerChanged
public final class SessionToken implements Bundleable {
private static final long WAIT_TIME_MS_FOR_SESSION3_TOKEN = 500;
/** Types of {@link SessionToken}. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef(value = {TYPE_SESSION, TYPE_SESSION_SERVICE, TYPE_LIBRARY_SERVICE})
public @interface TokenType {}
/** Type for {@link MediaSession}. */
public static final int TYPE_SESSION = 0;
/** Type for {@link MediaSessionService}. */
public static final int TYPE_SESSION_SERVICE = 1;
/** Type for {@link MediaLibraryService}. */
public static final int TYPE_LIBRARY_SERVICE = 2;
/** Type for {@link MediaSessionCompat}. */
/* package */ static final int TYPE_SESSION_LEGACY = 100;
/** Type for {@link MediaBrowserServiceCompat}. */
/* package */ static final int TYPE_BROWSER_SERVICE_LEGACY = 101;
private final SessionTokenImpl impl;
/**
* Creates a token for {@link MediaController} or {@link MediaBrowser} to connect to one of {@link
* MediaSessionService}, {@link MediaLibraryService}, or {@link MediaBrowserServiceCompat}.
*
* @param context The context.
* @param serviceComponent The component name of the service.
*/
public SessionToken(Context context, ComponentName serviceComponent) {
checkNotNull(context, "context must not be null");
checkNotNull(serviceComponent, "serviceComponent must not be null");
PackageManager manager = context.getPackageManager();
int uid = getUid(manager, serviceComponent.getPackageName());
int type;
if (isInterfaceDeclared(manager, MediaLibraryService.SERVICE_INTERFACE, serviceComponent)) {
type = TYPE_LIBRARY_SERVICE;
} else if (isInterfaceDeclared(
manager, MediaSessionService.SERVICE_INTERFACE, serviceComponent)) {
type = TYPE_SESSION_SERVICE;
} else if (isInterfaceDeclared(
manager, MediaBrowserServiceCompat.SERVICE_INTERFACE, serviceComponent)) {
type = TYPE_BROWSER_SERVICE_LEGACY;
} else {
throw new IllegalArgumentException(
serviceComponent
+ " doesn't implement none of"
+ " MediaSessionService, MediaLibraryService, MediaBrowserService nor"
+ " MediaBrowserServiceCompat. Use service's full name");
}
if (type != TYPE_BROWSER_SERVICE_LEGACY) {
impl = new SessionTokenImplBase(serviceComponent, uid, type);
} else {
impl = new SessionTokenImplLegacy(serviceComponent, uid);
}
}
/* package */ SessionToken(
int uid,
int type,
int version,
String packageName,
IMediaSession iSession,
Bundle tokenExtras) {
impl = new SessionTokenImplBase(uid, type, version, packageName, iSession, tokenExtras);
}
/* package */ SessionToken(Context context, MediaSessionCompat.Token compatToken) {
checkNotNull(context, "context must not be null");
checkNotNull(compatToken, "compatToken must not be null");
MediaControllerCompat controller = createMediaControllerCompat(context, compatToken);
String packageName = controller.getPackageName();
int uid = getUid(context.getPackageManager(), packageName);
Bundle extras = controller.getSessionInfo();
impl = new SessionTokenImplLegacy(compatToken, packageName, uid, extras);
}
/* package */ SessionToken(SessionTokenImpl impl) {
this.impl = impl;
}
private SessionToken(Bundle bundle) {
checkArgument(bundle.containsKey(keyForField(FIELD_IMPL_TYPE)), "Impl type needs to be set.");
@SessionTokenImplType int implType = bundle.getInt(keyForField(FIELD_IMPL_TYPE));
Bundle implBundle = checkNotNull(bundle.getBundle(keyForField(FIELD_IMPL)));
if (implType == IMPL_TYPE_BASE) {
impl = SessionTokenImplBase.CREATOR.fromBundle(implBundle);
} else {
impl = SessionTokenImplLegacy.CREATOR.fromBundle(implBundle);
}
}
@Override
public int hashCode() {
return impl.hashCode();
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof SessionToken)) {
return false;
}
SessionToken other = (SessionToken) obj;
return impl.equals(other.impl);
}
@Override
public String toString() {
return impl.toString();
}
/** Returns the uid of the session */
public int getUid() {
return impl.getUid();
}
/** Returns the package name of the session */
public String getPackageName() {
return impl.getPackageName();
}
/**
* Returns the service name of the session. It will be an empty string if the {@link #getType()
* type} is {@link #TYPE_SESSION}.
*/
public String getServiceName() {
return impl.getServiceName();
}
/**
* Returns the component name of the session. It will be {@code null} if the {@link #getType()
* type} is {@link #TYPE_SESSION}.
*/
@Nullable
/* package */ ComponentName getComponentName() {
return impl.getComponentName();
}
/**
* Returns the type of this token. One of {@link #TYPE_SESSION}, {@link #TYPE_SESSION_SERVICE}, or
* {@link #TYPE_LIBRARY_SERVICE}.
*/
public @TokenType int getType() {
return impl.getType();
}
/**
* Returns the library version of the session if the {@link #getType() type} is {@link
* #TYPE_SESSION}. Otherwise, it returns {@code 0}.
*
* <p>It will be the same as {@link MediaLibraryInfo#VERSION_INT} of the session, or less than
* {@code 1000000} if the session is a legacy session.
*/
public int getSessionVersion() {
return impl.getSessionVersion();
}
/**
* Returns the extra {@link Bundle} of this token.
*
* @see MediaSession.Builder#setExtras(Bundle)
*/
public Bundle getExtras() {
return impl.getExtras();
}
/* package */ boolean isLegacySession() {
return impl.isLegacySession();
}
@Nullable
/* package */ Object getBinder() {
return impl.getBinder();
}
/**
* Creates a token from {@link MediaSessionCompat.Token}.
*
* @return a {@link ListenableFuture} of {@link SessionToken}
*/
@UnstableApi
public static ListenableFuture<SessionToken> createSessionToken(
Context context, Object compatToken) {
checkNotNull(context, "context must not be null");
checkNotNull(compatToken, "compatToken must not be null");
checkArgument(compatToken instanceof MediaSessionCompat.Token);
HandlerThread thread = new HandlerThread("SessionTokenThread");
thread.start();
SettableFuture<SessionToken> future = SettableFuture.create();
// Try retrieving media3 token by connecting to the session.
MediaControllerCompat controller =
createMediaControllerCompat(context, (MediaSessionCompat.Token) compatToken);
String packageName = controller.getPackageName();
int uid = getUid(context.getPackageManager(), packageName);
Handler handler = new Handler(thread.getLooper());
controller.sendCommand(
MediaConstants.SESSION_COMMAND_REQUEST_SESSION3_TOKEN,
/* params= */ null,
new ResultReceiver(handler) {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
handler.removeCallbacksAndMessages(null);
future.set(SessionToken.CREATOR.fromBundle(resultData));
}
});
handler.postDelayed(
() -> {
// Timed out getting session3 token. Handle this as a legacy token.
SessionToken resultToken =
new SessionToken(
new SessionTokenImplLegacy(
(MediaSessionCompat.Token) compatToken,
packageName,
uid,
controller.getSessionInfo()));
future.set(resultToken);
},
WAIT_TIME_MS_FOR_SESSION3_TOKEN);
future.addListener(() -> thread.quit(), MoreExecutors.directExecutor());
return future;
}
/**
* Returns a {@link ImmutableSet} of {@link SessionToken} for media session services; {@link
* MediaSessionService}, {@link MediaLibraryService}, and {@link MediaBrowserServiceCompat}
* regardless of their activeness. This set represents media apps that publish {@link
* MediaSession}.
*
* <p>The app targeting API level 30 or higher must include a {@code <queries>} element in their
* manifest to get service tokens of other apps. See the following example and <a
* href="//developer.android.com/training/package-visibility">this guide</a> for more information.
*
* <pre>{@code
* <intent>
* <action android:name="androidx.media3.session.MediaSessionService" />
* </intent>
* <intent>
* <action android:name="androidx.media3.session.MediaLibraryService" />
* </intent>
* <intent>
* <action android:name="android.media.browse.MediaBrowserService" />
* </intent>
* }</pre>
*/
public static ImmutableSet<SessionToken> getAllServiceTokens(Context context) {
PackageManager pm = context.getPackageManager();
List<ResolveInfo> services = new ArrayList<>();
// If multiple actions are declared for a service, browser gets higher priority.
List<ResolveInfo> libraryServices =
pm.queryIntentServices(
new Intent(MediaLibraryService.SERVICE_INTERFACE), PackageManager.GET_META_DATA);
if (libraryServices != null) {
services.addAll(libraryServices);
}
List<ResolveInfo> sessionServices =
pm.queryIntentServices(
new Intent(MediaSessionService.SERVICE_INTERFACE), PackageManager.GET_META_DATA);
if (sessionServices != null) {
services.addAll(sessionServices);
}
List<ResolveInfo> browserServices =
pm.queryIntentServices(
new Intent(MediaBrowserServiceCompat.SERVICE_INTERFACE), PackageManager.GET_META_DATA);
if (browserServices != null) {
services.addAll(browserServices);
}
ImmutableSet.Builder<SessionToken> sessionServiceTokens = ImmutableSet.builder();
for (ResolveInfo service : services) {
if (service == null || service.serviceInfo == null) {
continue;
}
ServiceInfo serviceInfo = service.serviceInfo;
SessionToken token =
new SessionToken(context, new ComponentName(serviceInfo.packageName, serviceInfo.name));
sessionServiceTokens.add(token);
}
return sessionServiceTokens.build();
}
private static boolean isInterfaceDeclared(
PackageManager manager, String serviceInterface, ComponentName serviceComponent) {
Intent serviceIntent = new Intent(serviceInterface);
// Use queryIntentServices to find services with MediaLibraryService.SERVICE_INTERFACE.
// We cannot use resolveService with intent specified class name, because resolveService
// ignores actions if Intent.setClassName() is specified.
serviceIntent.setPackage(serviceComponent.getPackageName());
List<ResolveInfo> list =
manager.queryIntentServices(serviceIntent, PackageManager.GET_META_DATA);
if (list != null) {
for (int i = 0; i < list.size(); i++) {
ResolveInfo resolveInfo = list.get(i);
if (resolveInfo == null || resolveInfo.serviceInfo == null) {
continue;
}
if (TextUtils.equals(resolveInfo.serviceInfo.name, serviceComponent.getClassName())) {
return true;
}
}
}
return false;
}
private static int getUid(PackageManager manager, String packageName) {
try {
return manager.getApplicationInfo(packageName, 0).uid;
} catch (PackageManager.NameNotFoundException e) {
throw new IllegalArgumentException("Cannot find package " + packageName);
}
}
private static MediaControllerCompat createMediaControllerCompat(
Context context, MediaSessionCompat.Token sessionToken) {
return new MediaControllerCompat(context, sessionToken);
}
/* package */ interface SessionTokenImpl extends Bundleable {
boolean isLegacySession();
int getUid();
String getPackageName();
String getServiceName();
@Nullable
ComponentName getComponentName();
@TokenType
int getType();
int getSessionVersion();
Bundle getExtras();
@Nullable
Object getBinder();
}
// Bundleable implementation.
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({FIELD_IMPL_TYPE, FIELD_IMPL})
private @interface FieldNumber {}
private static final int FIELD_IMPL_TYPE = 0;
private static final int FIELD_IMPL = 1;
/** Types of {@link SessionTokenImpl} */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({IMPL_TYPE_BASE, IMPL_TYPE_LEGACY})
private @interface SessionTokenImplType {}
private static final int IMPL_TYPE_BASE = 0;
private static final int IMPL_TYPE_LEGACY = 1;
@UnstableApi
@Override
public Bundle toBundle() {
Bundle bundle = new Bundle();
if (impl instanceof SessionTokenImplBase) {
bundle.putInt(keyForField(FIELD_IMPL_TYPE), IMPL_TYPE_BASE);
} else {
bundle.putInt(keyForField(FIELD_IMPL_TYPE), IMPL_TYPE_LEGACY);
}
bundle.putBundle(keyForField(FIELD_IMPL), impl.toBundle());
return bundle;
}
/** Object that can restore {@link SessionToken} from a {@link Bundle}. */
@UnstableApi public static final Creator<SessionToken> CREATOR = SessionToken::fromBundle;
private static SessionToken fromBundle(Bundle bundle) {
return new SessionToken(bundle);
}
private static String keyForField(@FieldNumber int field) {
return Integer.toString(field, Character.MAX_RADIX);
}
}