All Downloads are FREE. Search and download functionalities are using the official Maven repository.

src.android.service.media.MediaBrowserService Maven / Gradle / Ivy

/*
 * Copyright (C) 2014 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.service.media;

import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SdkConstant;
import android.annotation.SdkConstant.SdkConstantType;
import android.app.Service;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ParceledListSlice;
import android.media.browse.MediaBrowser;
import android.media.browse.MediaBrowserUtils;
import android.media.session.MediaSession;
import android.media.session.MediaSessionManager;
import android.media.session.MediaSessionManager.RemoteUserInfo;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.ResultReceiver;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Pair;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;

/**
 * Base class for media browser services.
 * 

* Media browser 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 MediaSession}. *

* * To extend this class, you must declare the service in your manifest file with * an intent filter with the {@link #SERVICE_INTERFACE} action. * * For example: *

 * <service android:name=".MyMediaBrowserService"
 *          android:label="@string/service_name" >
 *     <intent-filter>
 *         <action android:name="android.media.browse.MediaBrowserService" />
 *     </intent-filter>
 * </service>
 * 
* */ public abstract class MediaBrowserService extends Service { private static final String TAG = "MediaBrowserService"; private static final boolean DBG = false; /** * The {@link Intent} that must be declared as handled by the service. */ @SdkConstant(SdkConstantType.SERVICE_ACTION) public static final String SERVICE_INTERFACE = "android.media.browse.MediaBrowserService"; /** * A key for passing the MediaItem to the ResultReceiver in getItem. * @hide */ @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) public static final String KEY_MEDIA_ITEM = "media_item"; private static final int RESULT_FLAG_OPTION_NOT_HANDLED = 1 << 0; private static final int RESULT_FLAG_ON_LOAD_ITEM_NOT_IMPLEMENTED = 1 << 1; private static final int RESULT_ERROR = -1; private static final int RESULT_OK = 0; /** @hide */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = { RESULT_FLAG_OPTION_NOT_HANDLED, RESULT_FLAG_ON_LOAD_ITEM_NOT_IMPLEMENTED }) private @interface ResultFlags { } private final ArrayMap mConnections = new ArrayMap<>(); private ConnectionRecord mCurConnection; private final Handler mHandler = new Handler(); private ServiceBinder mBinder; MediaSession.Token mSession; /** * All the info about a connection. */ private static class ConnectionRecord implements IBinder.DeathRecipient { public final MediaBrowserService service; public final String pkg; public final int pid; public final int uid; public final Bundle rootHints; public final IMediaBrowserServiceCallbacks callbacks; public final BrowserRoot root; public final HashMap>> subscriptions = new HashMap<>(); ConnectionRecord( MediaBrowserService service, String pkg, int pid, int uid, Bundle rootHints, IMediaBrowserServiceCallbacks callbacks, BrowserRoot root) { this.service = service; this.pkg = pkg; this.pid = pid; this.uid = uid; this.rootHints = rootHints; this.callbacks = callbacks; this.root = root; } @Override public void binderDied() { service.mHandler.post(new Runnable() { @Override public void run() { service.mConnections.remove(callbacks.asBinder()); } }); } } /** * Completion handler for asynchronous callback methods in {@link MediaBrowserService}. *

* Each of the methods that takes one of these to send the result must call * {@link #sendResult} to respond to the caller with the given results. If those * functions return without calling {@link #sendResult}, they must instead call * {@link #detach} before returning, and then may call {@link #sendResult} when * they are done. If more than one of those methods is called, an exception will * be thrown. * * @see #onLoadChildren * @see #onLoadItem */ public class Result { private Object mDebug; private boolean mDetachCalled; private boolean mSendResultCalled; @UnsupportedAppUsage private int mFlags; Result(Object debug) { mDebug = debug; } /** * Send the result back to the caller. */ public void sendResult(T result) { if (mSendResultCalled) { throw new IllegalStateException("sendResult() called twice for: " + mDebug); } mSendResultCalled = true; onResultSent(result, mFlags); } /** * Detach this message from the current thread and allow the {@link #sendResult} * call to happen later. */ public void detach() { if (mDetachCalled) { throw new IllegalStateException("detach() called when detach() had already" + " been called for: " + mDebug); } if (mSendResultCalled) { throw new IllegalStateException("detach() called when sendResult() had already" + " been called for: " + mDebug); } mDetachCalled = true; } boolean isDone() { return mDetachCalled || mSendResultCalled; } void setFlags(@ResultFlags int flags) { mFlags = flags; } /** * Called when the result is sent, after assertions about not being called twice * have happened. */ void onResultSent(T result, @ResultFlags int flags) { } } private static class ServiceBinder extends IMediaBrowserService.Stub { private WeakReference mService; private ServiceBinder(MediaBrowserService service) { mService = new WeakReference(service); } @Override public void connect(final String pkg, final Bundle rootHints, final IMediaBrowserServiceCallbacks callbacks) { MediaBrowserService service = mService.get(); if (service == null) { return; } final int pid = Binder.getCallingPid(); final int uid = Binder.getCallingUid(); if (!service.isValidPackage(pkg, uid)) { throw new IllegalArgumentException("Package/uid mismatch: uid=" + uid + " package=" + pkg); } service.mHandler.post(new Runnable() { @Override public void run() { final IBinder b = callbacks.asBinder(); // Clear out the old subscriptions. We are getting new ones. service.mConnections.remove(b); // Temporarily sets a placeholder ConnectionRecord to make // getCurrentBrowserInfo() work in onGetRoot(). service.mCurConnection = new ConnectionRecord( service, pkg, pid, uid, rootHints, callbacks, null); BrowserRoot root = service.onGetRoot(pkg, uid, rootHints); service.mCurConnection = null; // If they didn't return something, don't allow this client. if (root == null) { Log.i(TAG, "No root for client " + pkg + " from service " + getClass().getName()); try { callbacks.onConnectFailed(); } catch (RemoteException ex) { Log.w(TAG, "Calling onConnectFailed() failed. Ignoring. " + "pkg=" + pkg); } } else { try { ConnectionRecord connection = new ConnectionRecord( service, pkg, pid, uid, rootHints, callbacks, root); service.mConnections.put(b, connection); b.linkToDeath(connection, 0); if (service.mSession != null) { callbacks.onConnect(connection.root.getRootId(), service.mSession, connection.root.getExtras()); } } catch (RemoteException ex) { Log.w(TAG, "Calling onConnect() failed. Dropping client. " + "pkg=" + pkg); service.mConnections.remove(b); } } } }); } @Override public void disconnect(final IMediaBrowserServiceCallbacks callbacks) { MediaBrowserService service = mService.get(); if (service == null) { return; } service.mHandler.post(new Runnable() { @Override public void run() { final IBinder b = callbacks.asBinder(); // Clear out the old subscriptions. We are getting new ones. final ConnectionRecord old = service.mConnections.remove(b); if (old != null) { // TODO old.callbacks.asBinder().unlinkToDeath(old, 0); } } }); } @Override public void addSubscriptionDeprecated(String id, IMediaBrowserServiceCallbacks callbacks) { // do-nothing } @Override public void addSubscription(final String id, final IBinder token, final Bundle options, final IMediaBrowserServiceCallbacks callbacks) { MediaBrowserService service = mService.get(); if (service == null) { return; } service.mHandler.post(new Runnable() { @Override public void run() { final IBinder b = callbacks.asBinder(); // Get the record for the connection final ConnectionRecord connection = service.mConnections.get(b); if (connection == null) { Log.w(TAG, "addSubscription for callback that isn't registered id=" + id); return; } service.addSubscription(id, connection, token, options); } }); } @Override public void removeSubscriptionDeprecated( String id, IMediaBrowserServiceCallbacks callbacks) { // do-nothing } @Override public void removeSubscription(final String id, final IBinder token, final IMediaBrowserServiceCallbacks callbacks) { MediaBrowserService service = mService.get(); if (service == null) { return; } service.mHandler.post(new Runnable() { @Override public void run() { final IBinder b = callbacks.asBinder(); ConnectionRecord connection = service.mConnections.get(b); if (connection == null) { Log.w(TAG, "removeSubscription for callback that isn't registered id=" + id); return; } if (!service.removeSubscription(id, connection, token)) { Log.w(TAG, "removeSubscription called for " + id + " which is not subscribed"); } } }); } @Override public void getMediaItem(final String mediaId, final ResultReceiver receiver, final IMediaBrowserServiceCallbacks callbacks) { MediaBrowserService service = mService.get(); if (service == null) { return; } service.mHandler.post(new Runnable() { @Override public void run() { final IBinder b = callbacks.asBinder(); ConnectionRecord connection = service.mConnections.get(b); if (connection == null) { Log.w(TAG, "getMediaItem for callback that isn't registered id=" + mediaId); return; } service.performLoadItem(mediaId, connection, receiver); } }); } } @Override public void onCreate() { super.onCreate(); mBinder = new ServiceBinder(this); } @Override public IBinder onBind(Intent intent) { if (SERVICE_INTERFACE.equals(intent.getAction())) { return mBinder; } return null; } @Override public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { } /** * Called to get the root information for browsing by a particular client. *

* 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 clientPackageName The package name of the application which is * requesting access to browse media. * @param clientUid The uid of the application which is 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 String clientPackageName, int clientUid, @Nullable Bundle rootHints); /** * Called to get information about the children of a media item. *

* Implementations must call {@link Result#sendResult result.sendResult} * with the list of children. If loading the children will be an expensive * operation that should be performed on another thread, * {@link Result#detach result.detach} may be called before returning from * this function, and then {@link Result#sendResult result.sendResult} * called when the loading is complete. *

* In case the media item does not have any children, call {@link Result#sendResult} * with an empty list. When the given {@code parentId} is invalid, implementations must * call {@link Result#sendResult result.sendResult} with {@code null}, which will invoke * {@link MediaBrowser.SubscriptionCallback#onError}. *

* * @param parentId The id of the parent media item whose children are to be * queried. * @param result The Result to send the list of children to. */ public abstract void onLoadChildren(@NonNull String parentId, @NonNull Result> result); /** * Called to get information about the children of a media item. *

* Implementations must call {@link Result#sendResult result.sendResult} * with the list of children. If loading the children will be an expensive * operation that should be performed on another thread, * {@link Result#detach result.detach} may be called before returning from * this function, and then {@link Result#sendResult result.sendResult} * called when the loading is complete. *

* In case the media item does not have any children, call {@link Result#sendResult} * with an empty list. When the given {@code parentId} is invalid, implementations must * call {@link Result#sendResult result.sendResult} with {@code null}, which will invoke * {@link MediaBrowser.SubscriptionCallback#onError}. *

* * @param parentId The id of the parent media item whose children are to be * queried. * @param result The Result to send the list of children to. * @param options The bundle of service-specific arguments sent from the media * browser. The information returned through the result should be * affected by the contents of this bundle. */ public void onLoadChildren(@NonNull String parentId, @NonNull Result> result, @NonNull Bundle options) { // To support backward compatibility, when the implementation of MediaBrowserService doesn't // override onLoadChildren() with options, onLoadChildren() without options will be used // instead, and the options will be applied in the implementation of result.onResultSent(). result.setFlags(RESULT_FLAG_OPTION_NOT_HANDLED); onLoadChildren(parentId, result); } /** * Called to get information about a specific media item. *

* Implementations must call {@link Result#sendResult result.sendResult}. If * loading the item will be an expensive operation {@link Result#detach * result.detach} may be called before returning from this function, and * then {@link Result#sendResult result.sendResult} called when the item has * been loaded. *

* When the given {@code itemId} is invalid, implementations must call * {@link Result#sendResult result.sendResult} with {@code null}. *

* The default implementation will invoke {@link MediaBrowser.ItemCallback#onError}. *

* * @param itemId The id for the specific * {@link android.media.browse.MediaBrowser.MediaItem}. * @param result The Result to send the item to. */ public void onLoadItem(String itemId, Result result) { result.setFlags(RESULT_FLAG_ON_LOAD_ITEM_NOT_IMPLEMENTED); result.sendResult(null); } /** * Call to set the media session. *

* This should be called as soon as possible during the service's startup. * It may only be called once. * * @param token The token for the service's {@link MediaSession}. */ public void setSessionToken(final MediaSession.Token token) { if (token == null) { throw new IllegalArgumentException("Session token may not be null."); } if (mSession != null) { throw new IllegalStateException("The session token has already been set."); } mSession = token; mHandler.post(new Runnable() { @Override public void run() { Iterator iter = mConnections.values().iterator(); while (iter.hasNext()) { ConnectionRecord connection = iter.next(); try { connection.callbacks.onConnect(connection.root.getRootId(), token, connection.root.getExtras()); } catch (RemoteException e) { Log.w(TAG, "Connection for " + connection.pkg + " is no longer valid."); iter.remove(); } } } }); } /** * Gets the session token, or null if it has not yet been created * or if it has been destroyed. */ public @Nullable MediaSession.Token getSessionToken() { return mSession; } /** * Gets the root hints sent from the currently connected {@link MediaBrowser}. * The root hints are service-specific arguments included in an optional bundle sent 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. * * @throws IllegalStateException If this method is called outside of {@link #onGetRoot} or * {@link #onLoadChildren} or {@link #onLoadItem}. * @see MediaBrowserService.BrowserRoot#EXTRA_RECENT * @see MediaBrowserService.BrowserRoot#EXTRA_OFFLINE * @see MediaBrowserService.BrowserRoot#EXTRA_SUGGESTED */ public final Bundle getBrowserRootHints() { if (mCurConnection == null) { throw new IllegalStateException("This should be called inside of onGetRoot or" + " onLoadChildren or onLoadItem methods"); } return mCurConnection.rootHints == null ? null : new Bundle(mCurConnection.rootHints); } /** * Gets the browser information who sent the current request. * * @throws IllegalStateException If this method is called outside of {@link #onGetRoot} or * {@link #onLoadChildren} or {@link #onLoadItem}. * @see MediaSessionManager#isTrustedForMediaControl(RemoteUserInfo) */ public final RemoteUserInfo getCurrentBrowserInfo() { if (mCurConnection == null) { throw new IllegalStateException("This should be called inside of onGetRoot or" + " onLoadChildren or onLoadItem methods"); } return new RemoteUserInfo(mCurConnection.pkg, mCurConnection.pid, mCurConnection.uid); } /** * Notifies all connected media browsers that the children of * the specified parent id have changed in some way. * This will cause browsers to fetch subscribed content again. * * @param parentId The id of the parent media item whose * children changed. */ public void notifyChildrenChanged(@NonNull String parentId) { notifyChildrenChangedInternal(parentId, null); } /** * Notifies all connected media browsers that the children of * the specified parent id have changed in some way. * This will cause browsers to fetch subscribed content again. * * @param parentId The id of the parent media item whose * children changed. * @param options The bundle of service-specific arguments to send * to the media browser. The contents of this bundle may * contain the information about the change. */ public void notifyChildrenChanged(@NonNull String parentId, @NonNull Bundle options) { if (options == null) { throw new IllegalArgumentException("options cannot be null in notifyChildrenChanged"); } notifyChildrenChangedInternal(parentId, options); } private void notifyChildrenChangedInternal(final String parentId, final Bundle options) { if (parentId == null) { throw new IllegalArgumentException("parentId cannot be null in notifyChildrenChanged"); } mHandler.post(new Runnable() { @Override public void run() { for (IBinder binder : mConnections.keySet()) { ConnectionRecord connection = mConnections.get(binder); List> callbackList = connection.subscriptions.get(parentId); if (callbackList != null) { for (Pair callback : callbackList) { if (MediaBrowserUtils.hasDuplicatedItems(options, callback.second)) { performLoadChildren(parentId, connection, callback.second); } } } } } }); } /** * Return whether the given package is one of the ones that is owned by the uid. */ private boolean isValidPackage(String pkg, int uid) { if (pkg == null) { return false; } final PackageManager pm = getPackageManager(); final String[] packages = pm.getPackagesForUid(uid); final int N = packages.length; for (int i = 0; i < N; i++) { if (packages[i].equals(pkg)) { return true; } } return false; } /** * Save the subscription and if it is a new subscription send the results. */ private void addSubscription(String id, ConnectionRecord connection, IBinder token, Bundle options) { // Save the subscription List> callbackList = connection.subscriptions.get(id); if (callbackList == null) { callbackList = new ArrayList<>(); } for (Pair callback : callbackList) { if (token == callback.first && MediaBrowserUtils.areSameOptions(options, callback.second)) { return; } } callbackList.add(new Pair<>(token, options)); connection.subscriptions.put(id, callbackList); // send the results performLoadChildren(id, connection, options); } /** * Remove the subscription. */ private boolean removeSubscription(String id, ConnectionRecord connection, IBinder token) { if (token == null) { return connection.subscriptions.remove(id) != null; } boolean removed = false; List> callbackList = connection.subscriptions.get(id); if (callbackList != null) { Iterator> iter = callbackList.iterator(); while (iter.hasNext()) { if (token == iter.next().first) { removed = true; iter.remove(); } } if (callbackList.size() == 0) { connection.subscriptions.remove(id); } } return removed; } /** * Call onLoadChildren and then send the results back to the connection. *

* Callers must make sure that this connection is still connected. */ private void performLoadChildren(final String parentId, final ConnectionRecord connection, final Bundle options) { final Result> result = new Result>(parentId) { @Override void onResultSent(List list, @ResultFlags int flag) { if (mConnections.get(connection.callbacks.asBinder()) != connection) { if (DBG) { Log.d(TAG, "Not sending onLoadChildren result for connection that has" + " been disconnected. pkg=" + connection.pkg + " id=" + parentId); } return; } List filteredList = (flag & RESULT_FLAG_OPTION_NOT_HANDLED) != 0 ? applyOptions(list, options) : list; final ParceledListSlice pls; if (filteredList == null) { pls = null; } else { pls = new ParceledListSlice<>(filteredList); // Limit the size of initial Parcel to prevent binder buffer overflow // as onLoadChildren is an async binder call. pls.setInlineCountLimit(1); } try { connection.callbacks.onLoadChildren(parentId, pls, options); } catch (RemoteException ex) { // The other side is in the process of crashing. Log.w(TAG, "Calling onLoadChildren() failed for id=" + parentId + " package=" + connection.pkg); } } }; mCurConnection = connection; if (options == null) { onLoadChildren(parentId, result); } else { onLoadChildren(parentId, result, options); } mCurConnection = null; if (!result.isDone()) { throw new IllegalStateException("onLoadChildren must call detach() or sendResult()" + " before returning for package=" + connection.pkg + " id=" + parentId); } } private List applyOptions(List list, final Bundle options) { if (list == null) { return null; } int page = options.getInt(MediaBrowser.EXTRA_PAGE, -1); int pageSize = options.getInt(MediaBrowser.EXTRA_PAGE_SIZE, -1); if (page == -1 && pageSize == -1) { return list; } int fromIndex = pageSize * page; int toIndex = fromIndex + pageSize; if (page < 0 || pageSize < 1 || fromIndex >= list.size()) { return Collections.EMPTY_LIST; } if (toIndex > list.size()) { toIndex = list.size(); } return list.subList(fromIndex, toIndex); } private void performLoadItem(String itemId, final ConnectionRecord connection, final ResultReceiver receiver) { final Result result = new Result(itemId) { @Override void onResultSent(MediaBrowser.MediaItem item, @ResultFlags int flag) { if (mConnections.get(connection.callbacks.asBinder()) != connection) { if (DBG) { Log.d(TAG, "Not sending onLoadItem result for connection that has" + " been disconnected. pkg=" + connection.pkg + " id=" + itemId); } return; } if ((flag & RESULT_FLAG_ON_LOAD_ITEM_NOT_IMPLEMENTED) != 0) { receiver.send(RESULT_ERROR, null); return; } Bundle bundle = new Bundle(); bundle.putParcelable(KEY_MEDIA_ITEM, item); receiver.send(RESULT_OK, bundle); } }; mCurConnection = connection; onLoadItem(itemId, result); mCurConnection = null; if (!result.isDone()) { throw new IllegalStateException("onLoadItem must call detach() or sendResult()" + " before returning for id=" + itemId); } } /** * Contains information that the browser service needs to send to the client * when first connected. */ public static final class BrowserRoot { /** * The lookup key for a boolean that indicates whether the browser service should return a * browser root for recently played media items. * *

When creating a media browser for a given media browser service, this key can be * supplied as a root hint for retrieving media items that are recently played. * If the media browser service can provide such media items, the implementation must return * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back. * *

The root hint may contain multiple keys. * * @see #EXTRA_OFFLINE * @see #EXTRA_SUGGESTED */ public static final String EXTRA_RECENT = "android.service.media.extra.RECENT"; /** * The lookup key for a boolean that indicates whether the browser service should return a * browser root for offline media items. * *

When creating a media browser for a given media browser service, this key can be * supplied as a root hint for retrieving media items that are can be played without an * internet connection. * If the media browser service can provide such media items, the implementation must return * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back. * *

The root hint may contain multiple keys. * * @see #EXTRA_RECENT * @see #EXTRA_SUGGESTED */ public static final String EXTRA_OFFLINE = "android.service.media.extra.OFFLINE"; /** * The lookup key for a boolean that indicates whether the browser service should return a * browser root for suggested media items. * *

When creating a media browser for a given media browser service, this key can be * supplied as a root hint for retrieving the media items suggested by the media browser * service. The list of media items passed in {@link android.media.browse.MediaBrowser.SubscriptionCallback#onChildrenLoaded(String, List)} * is considered ordered by relevance, first being the top suggestion. * If the media browser service can provide such media items, the implementation must return * the key in the root hint when {@link #onGetRoot(String, int, Bundle)} is called back. * *

The root hint may contain multiple keys. * * @see #EXTRA_RECENT * @see #EXTRA_OFFLINE */ public static final String EXTRA_SUGGESTED = "android.service.media.extra.SUGGESTED"; private final String mRootId; private final Bundle mExtras; /** * Constructs a browser root. * @param rootId The root id for browsing. * @param extras Any extras about the browser service. */ public BrowserRoot(@NonNull String rootId, @Nullable Bundle extras) { if (rootId == null) { throw new IllegalArgumentException("The root id in BrowserRoot cannot be null. " + "Use null for BrowserRoot instead."); } mRootId = rootId; mExtras = extras; } /** * Gets the root id for browsing. */ public String getRootId() { return mRootId; } /** * Gets any extras about the browser service. */ public Bundle getExtras() { return mExtras; } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy