/*
* 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.Looper;
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.annotation.RequiresApi;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media3.common.Bundleable;
import androidx.media3.common.C;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
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(
"Failed to resolve SessionToken for "
+ serviceComponent
+ ". Manifest doesn't declare one of either MediaSessionService, MediaLibraryService,"
+ " MediaBrowserService or MediaBrowserServiceCompat. Use service's full name.");
}
if (type != TYPE_BROWSER_SERVICE_LEGACY) {
impl = new SessionTokenImplBase(serviceComponent, uid, type);
} else {
impl = new SessionTokenImplLegacy(serviceComponent, uid);
}
}
/** Creates a session token connected to a Media3 session. */
/* package */ SessionToken(
int uid,
int type,
int libraryVersion,
int interfaceVersion,
String packageName,
IMediaSession iSession,
Bundle tokenExtras) {
impl =
new SessionTokenImplBase(
uid, type, libraryVersion, interfaceVersion, packageName, iSession, tokenExtras);
}
/** Creates a session token connected to a legacy media session. */
private SessionToken(MediaSessionCompat.Token token, String packageName, int uid, Bundle extras) {
this.impl = new SessionTokenImplLegacy(token, packageName, uid, extras);
}
private SessionToken(Bundle bundle) {
checkArgument(bundle.containsKey(FIELD_IMPL_TYPE), "Impl type needs to be set.");
@SessionTokenImplType int implType = bundle.getInt(FIELD_IMPL_TYPE);
Bundle implBundle = checkNotNull(bundle.getBundle(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 process, or {@link C#INDEX_UNSET} if the UID can't be determined
* due to missing <a href="https://developer.android.com/training/package-visibility">package
* visibility</a>.
*/
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.getLibraryVersion();
}
/**
* Returns the interface version of the session if the {@link #getType() type} is {@link
* #TYPE_SESSION}. Otherwise, it returns {@code 0}.
*/
@UnstableApi
public int getInterfaceVersion() {
return impl.getInterfaceVersion();
}
/**
* 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 a {@link android.media.session.MediaSession.Token}.
*
* @param context A {@link Context}.
* @param token The {@link android.media.session.MediaSession.Token}.
* @return A {@link ListenableFuture} for the {@link SessionToken}.
*/
@SuppressWarnings("UnnecessarilyFullyQualified") // Avoiding clash with Media3 MediaSession.
@UnstableApi
@RequiresApi(21)
public static ListenableFuture<SessionToken> createSessionToken(
Context context, android.media.session.MediaSession.Token token) {
return createSessionToken(context, MediaSessionCompat.Token.fromToken(token));
}
/**
* Creates a token from a {@link android.media.session.MediaSession.Token}.
*
* @param context A {@link Context}.
* @param token The {@link android.media.session.MediaSession.Token}.
* @param completionLooper The {@link Looper} on which the returned {@link ListenableFuture}
* completes. This {@link Looper} can't be used to call {@code future.get()} on the returned
* {@link ListenableFuture}.
* @return A {@link ListenableFuture} for the {@link SessionToken}.
*/
@SuppressWarnings("UnnecessarilyFullyQualified") // Avoiding clash with Media3 MediaSession.
@UnstableApi
@RequiresApi(21)
public static ListenableFuture<SessionToken> createSessionToken(
Context context, android.media.session.MediaSession.Token token, Looper completionLooper) {
return createSessionToken(context, MediaSessionCompat.Token.fromToken(token), completionLooper);
}
/**
* Creates a token from a {@link MediaSessionCompat.Token}.
*
* @param context A {@link Context}.
* @param compatToken The {@link MediaSessionCompat.Token}.
* @return A {@link ListenableFuture} for the {@link SessionToken}.
*/
@UnstableApi
public static ListenableFuture<SessionToken> createSessionToken(
Context context, MediaSessionCompat.Token compatToken) {
HandlerThread thread = new HandlerThread("SessionTokenThread");
thread.start();
ListenableFuture<SessionToken> tokenFuture =
createSessionToken(context, compatToken, thread.getLooper());
tokenFuture.addListener(thread::quit, MoreExecutors.directExecutor());
return tokenFuture;
}
/**
* Creates a token from a {@link MediaSessionCompat.Token}.
*
* @param context A {@link Context}.
* @param compatToken The {@link MediaSessionCompat.Token}.
* @param completionLooper The {@link Looper} on which the returned {@link ListenableFuture}
* completes. This {@link Looper} can't be used to call {@code future.get()} on the returned
* {@link ListenableFuture}.
* @return A {@link ListenableFuture} for the {@link SessionToken}.
*/
@UnstableApi
public static ListenableFuture<SessionToken> createSessionToken(
Context context, MediaSessionCompat.Token compatToken, Looper completionLooper) {
checkNotNull(context, "context must not be null");
checkNotNull(compatToken, "compatToken must not be null");
SettableFuture<SessionToken> future = SettableFuture.create();
// Try retrieving media3 token by connecting to the session.
MediaControllerCompat controller = new MediaControllerCompat(context, compatToken);
String packageName = controller.getPackageName();
Handler handler = new Handler(completionLooper);
Runnable createFallbackLegacyToken =
() -> {
int uid = getUid(context.getPackageManager(), packageName);
SessionToken resultToken =
new SessionToken(compatToken, packageName, uid, controller.getSessionInfo());
future.set(resultToken);
};
// Post creating a fallback token if the command receives no result after a timeout.
handler.postDelayed(createFallbackLegacyToken, WAIT_TIME_MS_FOR_SESSION3_TOKEN);
controller.sendCommand(
MediaConstants.SESSION_COMMAND_REQUEST_SESSION3_TOKEN,
/* params= */ null,
new ResultReceiver(handler) {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
// Remove timeout callback.
handler.removeCallbacksAndMessages(null);
try {
future.set(SessionToken.CREATOR.fromBundle(resultData));
} catch (RuntimeException e) {
// Fallback to a legacy token if we receive an unexpected result, e.g. a legacy
// session acknowledging commands by a success callback.
createFallbackLegacyToken.run();
}
}
});
return future;
}
/**
* Returns an {@link ImmutableSet} of {@linkplain SessionToken session tokens} for media session
* services; {@link MediaSessionService}, {@link MediaLibraryService}, and {@link
* MediaBrowserServiceCompat} regardless of their activeness.
*
* <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>
*/
// We ask the app to declare the <queries> tags, so it's expected that they are missing.
@SuppressWarnings("QueryPermissionsNeeded")
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();
}
// We ask the app to declare the <queries> tags, so it's expected that they are missing.
@SuppressWarnings("QueryPermissionsNeeded")
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) {
return C.INDEX_UNSET;
}
}
/* package */ interface SessionTokenImpl extends Bundleable {
boolean isLegacySession();
int getUid();
String getPackageName();
String getServiceName();
@Nullable
ComponentName getComponentName();
@TokenType
int getType();
int getLibraryVersion();
int getInterfaceVersion();
Bundle getExtras();
@Nullable
Object getBinder();
}
// Bundleable implementation.
private static final String FIELD_IMPL_TYPE = Util.intToStringMaxRadix(0);
private static final String FIELD_IMPL = Util.intToStringMaxRadix(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(FIELD_IMPL_TYPE, IMPL_TYPE_BASE);
} else {
bundle.putInt(FIELD_IMPL_TYPE, IMPL_TYPE_LEGACY);
}
bundle.putBundle(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);
}
}