/* * Copyright (C) 2016 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.car.cluster.renderer; import static android.content.PermissionChecker.PERMISSION_GRANTED; import android.annotation.CallSuper; import android.annotation.MainThread; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SystemApi; import android.annotation.UserIdInt; import android.app.ActivityOptions; import android.app.Service; import android.car.Car; import android.car.CarLibLog; import android.car.cluster.ClusterActivityState; import android.car.navigation.CarNavigationInstrumentCluster; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ProviderInfo; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.UserHandle; import android.util.Log; import android.util.LruCache; import android.view.KeyEvent; import com.android.internal.annotations.GuardedBy; import java.io.FileDescriptor; import java.io.PrintWriter; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import java.util.stream.Collectors; /** * A service used for interaction between Car Service and Instrument Cluster. Car Service may * provide internal navigation binder interface to Navigation App and all notifications will be * eventually land in the {@link NavigationRenderer} returned by {@link #getNavigationRenderer()}. * *

To extend this class, you must declare the service in your manifest file with * the {@code android.car.permission.BIND_INSTRUMENT_CLUSTER_RENDERER_SERVICE} permission *

 * <service android:name=".MyInstrumentClusterService"
 *          android:permission="android.car.permission.BIND_INSTRUMENT_CLUSTER_RENDERER_SERVICE">
 * </service>
*

Also, you will need to register this service in the following configuration file: * {@code packages/services/Car/service/res/values/config.xml} * * @hide */ @SystemApi public abstract class InstrumentClusterRenderingService extends Service { /** * Key to pass IInstrumentClusterHelper binder in onBind call {@link Intent} through extra * {@link Bundle). Both extra bundle and binder itself use this key. * * @hide */ public static final String EXTRA_BUNDLE_KEY_FOR_INSTRUMENT_CLUSTER_HELPER = "android.car.cluster.renderer.IInstrumentClusterHelper"; private static final String TAG = CarLibLog.TAG_CLUSTER; private static final String BITMAP_QUERY_WIDTH = "w"; private static final String BITMAP_QUERY_HEIGHT = "h"; private static final String BITMAP_QUERY_OFFLANESALPHA = "offLanesAlpha"; private final Handler mUiHandler = new Handler(Looper.getMainLooper()); private final Object mLock = new Object(); // Main thread only private RendererBinder mRendererBinder; private ActivityOptions mActivityOptions; private ClusterActivityState mActivityState; private ComponentName mNavigationComponent; @GuardedBy("mLock") private ContextOwner mNavContextOwner; @GuardedBy("mLock") private IInstrumentClusterHelper mInstrumentClusterHelper; private static final int IMAGE_CACHE_SIZE_BYTES = 4 * 1024 * 1024; /* 4 mb */ private final LruCache mCache = new LruCache( IMAGE_CACHE_SIZE_BYTES) { @Override protected int sizeOf(String key, Bitmap value) { return value.getByteCount(); } }; private static class ContextOwner { final int mUid; final int mPid; final Set mPackageNames; final Set mAuthorities; ContextOwner(int uid, int pid, PackageManager packageManager) { mUid = uid; mPid = pid; String[] packageNames = uid != 0 ? packageManager.getPackagesForUid(uid) : null; mPackageNames = packageNames != null ? Collections.unmodifiableSet(new HashSet<>(Arrays.asList(packageNames))) : Collections.emptySet(); mAuthorities = Collections.unmodifiableSet(mPackageNames.stream() .map(packageName -> getAuthoritiesForPackage(packageManager, packageName)) .flatMap(Collection::stream) .collect(Collectors.toSet())); } @Override public String toString() { return "{uid: " + mUid + ", pid: " + mPid + ", packagenames: " + mPackageNames + ", authorities: " + mAuthorities + "}"; } private List getAuthoritiesForPackage(PackageManager packageManager, String packageName) { try { ProviderInfo[] providers = packageManager.getPackageInfo(packageName, PackageManager.GET_PROVIDERS).providers; if (providers == null) { return Collections.emptyList(); } return Arrays.stream(providers) .map(provider -> provider.authority) .collect(Collectors.toList()); } catch (PackageManager.NameNotFoundException e) { Log.w(TAG, "Package name not found while retrieving content provider authorities: " + packageName); return Collections.emptyList(); } } } @Override @CallSuper public IBinder onBind(Intent intent) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "onBind, intent: " + intent); } Bundle bundle = intent.getBundleExtra(EXTRA_BUNDLE_KEY_FOR_INSTRUMENT_CLUSTER_HELPER); IBinder binder = null; if (bundle != null) { binder = bundle.getBinder(EXTRA_BUNDLE_KEY_FOR_INSTRUMENT_CLUSTER_HELPER); } if (binder == null) { Log.wtf(TAG, "IInstrumentClusterHelper not passed through binder"); } else { synchronized (mLock) { mInstrumentClusterHelper = IInstrumentClusterHelper.Stub.asInterface(binder); } } if (mRendererBinder == null) { mRendererBinder = new RendererBinder(getNavigationRenderer()); } return mRendererBinder; } /** * Returns {@link NavigationRenderer} or null if it's not supported. This renderer will be * shared with the navigation context owner (application holding navigation focus). */ @MainThread @Nullable public abstract NavigationRenderer getNavigationRenderer(); /** * Called when key event that was addressed to instrument cluster display has been received. */ @MainThread public void onKeyEvent(@NonNull KeyEvent keyEvent) { } /** * Called when a navigation application becomes a context owner (receives navigation focus) and * its {@link Car#CATEGORY_NAVIGATION} activity is launched. */ @MainThread public void onNavigationComponentLaunched() { } /** * Called when the current context owner (application holding navigation focus) releases the * focus and its {@link Car#CAR_CATEGORY_NAVIGATION} activity is ready to be replaced by a * system default. */ @MainThread public void onNavigationComponentReleased() { } @Nullable private IInstrumentClusterHelper getClusterHelper() { synchronized (mLock) { if (mInstrumentClusterHelper == null) { Log.w("mInstrumentClusterHelper still null, should wait until onBind", new RuntimeException()); } return mInstrumentClusterHelper; } } /** * Start Activity in fixed mode. * *

Activity launched in this way will stay visible across crash, package updatge * or other Activity launch. So this should be carefully used for case like apps running * in instrument cluster.

* *

Only one Activity can stay in this mode for a display and launching other Activity * with this call means old one get out of the mode. Alternatively * {@link #stopFixedActivityMode(int)} can be called to get the top activitgy out of this * mode.

* * @param intent Should include specific {@code ComponentName}. * @param options Should include target display. * @param userId Target user id * @return {@code true} if succeeded. {@code false} may mean the target component is not ready * or available. Note that failure can happen during early boot-up stage even if the * target Activity is in normal state and client should retry when it fails. Once it is * successfully launched, car service will guarantee that it is running across crash or * other events. * * @hide */ protected boolean startFixedActivityModeForDisplayAndUser(@NonNull Intent intent, @NonNull ActivityOptions options, @UserIdInt int userId) { IInstrumentClusterHelper helper = getClusterHelper(); if (helper == null) { return false; } try { return helper.startFixedActivityModeForDisplayAndUser(intent, options.toBundle(), userId); } catch (RemoteException e) { Log.w("Remote exception from car service", e); // Probably car service will restart and rebind. So do nothing. } return false; } /** * Stop fixed mode for top Activity in the display. Crashing or launching other Activity * will not re-launch the top Activity any more. * * @hide */ protected void stopFixedActivityMode(int displayId) { IInstrumentClusterHelper helper = getClusterHelper(); if (helper == null) { return; } try { helper.stopFixedActivityMode(displayId); } catch (RemoteException e) { Log.w("Remote exception from car service, displayId:" + displayId, e); // Probably car service will restart and rebind. So do nothing. } } /** * Updates the cluster navigation activity by checking which activity to show (an activity of * the {@link #mNavContextOwner}). If not yet launched, it will do so. */ private void updateNavigationActivity() { ContextOwner contextOwner = getNavigationContextOwner(); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, String.format("updateNavigationActivity (mActivityOptions: %s, " + "mActivityState: %s, mNavContextOwnerUid: %s)", mActivityOptions, mActivityState, contextOwner)); } if (contextOwner == null || contextOwner.mUid == 0 || mActivityOptions == null || mActivityState == null || !mActivityState.isVisible()) { // We are not yet ready to display an activity on the cluster if (mNavigationComponent != null) { mNavigationComponent = null; onNavigationComponentReleased(); } return; } ComponentName component = getNavigationComponentByOwner(contextOwner); if (Objects.equals(mNavigationComponent, component)) { // We have already launched this component. if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Already launched component: " + component); } return; } if (component == null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "No component found for owner: " + contextOwner); } return; } if (!startNavigationActivity(component)) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unable to launch component: " + component); } return; } mNavigationComponent = component; onNavigationComponentLaunched(); } /** * Returns a component with category {@link Car#CAR_CATEGORY_NAVIGATION} from the same package * as the given navigation context owner. */ @Nullable private ComponentName getNavigationComponentByOwner(ContextOwner contextOwner) { for (String packageName : contextOwner.mPackageNames) { ComponentName component = getComponentFromPackage(packageName); if (component != null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Found component: " + component); } return component; } } return null; } private ContextOwner getNavigationContextOwner() { synchronized (mLock) { return mNavContextOwner; } } @Nullable private ComponentName getComponentFromPackage(@NonNull String packageName) { PackageManager packageManager = getPackageManager(); // Check package permission. if (packageManager.checkPermission(Car.PERMISSION_CAR_DISPLAY_IN_CLUSTER, packageName) != PERMISSION_GRANTED) { Log.i(TAG, String.format("Package '%s' doesn't have permission %s", packageName, Car.PERMISSION_CAR_DISPLAY_IN_CLUSTER)); return null; } Intent intent = new Intent(Intent.ACTION_MAIN) .addCategory(Car.CAR_CATEGORY_NAVIGATION) .setPackage(packageName); List resolveList = packageManager.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER); if (resolveList == null || resolveList.isEmpty() || resolveList.get(0).getComponentInfo() == null) { Log.i(TAG, "Failed to resolve an intent: " + intent); return null; } // In case of multiple matching activities in the same package, we pick the first one. return resolveList.get(0).getComponentInfo().getComponentName(); } /** * Starts an activity on the cluster using the given component. * * @return false if the activity couldn't be started. */ protected boolean startNavigationActivity(@NonNull ComponentName component) { // Create an explicit intent. Intent intent = new Intent(); intent.setComponent(component); intent.putExtra(Car.CAR_EXTRA_CLUSTER_ACTIVITY_STATE, mActivityState.toBundle()); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { startActivityAsUser(intent, mActivityOptions.toBundle(), UserHandle.CURRENT); Log.i(TAG, String.format("Activity launched: %s (options: %s, displayId: %d)", mActivityOptions, intent, mActivityOptions.getLaunchDisplayId())); } catch (ActivityNotFoundException ex) { Log.w(TAG, "Unable to find activity for intent: " + intent); return false; } catch (Exception ex) { // Catch all other possible exception to prevent service disruption by misbehaving // applications. Log.e(TAG, "Error trying to launch intent: " + intent + ". Ignored", ex); return false; } return true; } /** * @hide * @deprecated Use {@link #setClusterActivityLaunchOptions(ActivityOptions)} instead. */ @Deprecated public void setClusterActivityLaunchOptions(String category, ActivityOptions activityOptions) { setClusterActivityLaunchOptions(activityOptions); } /** * Sets configuration for activities that should be launched directly in the instrument * cluster. * * @param activityOptions contains information of how to start cluster activity (on what display * or activity stack). * @hide */ public void setClusterActivityLaunchOptions(ActivityOptions activityOptions) { mActivityOptions = activityOptions; updateNavigationActivity(); } /** * @hide * @deprecated Use {@link #setClusterActivityState(ClusterActivityState)} instead. */ @Deprecated public void setClusterActivityState(String category, Bundle state) { setClusterActivityState(ClusterActivityState.fromBundle(state)); } /** * Set activity state (such as unobscured bounds). * * @param state pass information about activity state, see * {@link android.car.cluster.ClusterActivityState} * @hide */ public void setClusterActivityState(ClusterActivityState state) { mActivityState = state; updateNavigationActivity(); } @CallSuper @Override protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) { synchronized (mLock) { writer.println("**" + getClass().getSimpleName() + "**"); writer.println("renderer binder: " + mRendererBinder); if (mRendererBinder != null) { writer.println("navigation renderer: " + mRendererBinder.mNavigationRenderer); } writer.println("navigation focus owner: " + getNavigationContextOwner()); writer.println("activity options: " + mActivityOptions); writer.println("activity state: " + mActivityState); writer.println("current nav component: " + mNavigationComponent); writer.println("current nav packages: " + getNavigationContextOwner().mPackageNames); writer.println("mInstrumentClusterHelper" + mInstrumentClusterHelper); } } private class RendererBinder extends IInstrumentCluster.Stub { private final NavigationRenderer mNavigationRenderer; RendererBinder(NavigationRenderer navigationRenderer) { mNavigationRenderer = navigationRenderer; } @Override public IInstrumentClusterNavigation getNavigationService() throws RemoteException { return new NavigationBinder(mNavigationRenderer); } @Override public void setNavigationContextOwner(int uid, int pid) throws RemoteException { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Updating navigation ownership to uid: " + uid + ", pid: " + pid); } synchronized (mLock) { mNavContextOwner = new ContextOwner(uid, pid, getPackageManager()); } mUiHandler.post(InstrumentClusterRenderingService.this::updateNavigationActivity); } @Override public void onKeyEvent(KeyEvent keyEvent) throws RemoteException { mUiHandler.post(() -> InstrumentClusterRenderingService.this.onKeyEvent(keyEvent)); } } private class NavigationBinder extends IInstrumentClusterNavigation.Stub { private final NavigationRenderer mNavigationRenderer; NavigationBinder(NavigationRenderer navigationRenderer) { mNavigationRenderer = navigationRenderer; } @Override @SuppressWarnings("deprecation") public void onNavigationStateChanged(@Nullable Bundle bundle) throws RemoteException { assertClusterManagerPermission(); assertContextOwnership(); mUiHandler.post(() -> { if (mNavigationRenderer != null) { mNavigationRenderer.onNavigationStateChanged(bundle); } }); } @Override public CarNavigationInstrumentCluster getInstrumentClusterInfo() throws RemoteException { assertClusterManagerPermission(); return runAndWaitResult(() -> mNavigationRenderer.getNavigationProperties()); } private void assertContextOwnership() { int uid = getCallingUid(); int pid = getCallingPid(); synchronized (mLock) { if (mNavContextOwner.mUid != uid || mNavContextOwner.mPid != pid) { throw new IllegalStateException("Client {uid:" + uid + ", pid: " + pid + "} is" + " not an owner of APP_FOCUS_TYPE_NAVIGATION " + mNavContextOwner); } } } } private void assertClusterManagerPermission() { if (checkCallingOrSelfPermission(Car.PERMISSION_CAR_NAVIGATION_MANAGER) != PackageManager.PERMISSION_GRANTED) { throw new SecurityException("requires " + Car.PERMISSION_CAR_NAVIGATION_MANAGER); } } private E runAndWaitResult(final Supplier supplier) { final CountDownLatch latch = new CountDownLatch(1); final AtomicReference result = new AtomicReference<>(); mUiHandler.post(() -> { result.set(supplier.get()); latch.countDown(); }); try { latch.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } return result.get(); } /** * Fetches a bitmap from the navigation context owner (application holding navigation focus). * It returns null if: *
    *
  • there is no navigation context owner *
  • or if the {@link Uri} is invalid *
  • or if it references a process other than the current navigation context owner *
* This is a costly operation. Returned bitmaps should be cached and fetching should be done on * a secondary thread. * * @throws IllegalArgumentException if {@code uri} does not have width and height query params. * * @deprecated Replaced by {@link #getBitmap(Uri, int, int)}. */ @Deprecated @Nullable public Bitmap getBitmap(Uri uri) { try { if (uri.getQueryParameter(BITMAP_QUERY_WIDTH).isEmpty() || uri.getQueryParameter( BITMAP_QUERY_HEIGHT).isEmpty()) { throw new IllegalArgumentException( "Uri must have '" + BITMAP_QUERY_WIDTH + "' and '" + BITMAP_QUERY_HEIGHT + "' query parameters"); } ContextOwner contextOwner = getNavigationContextOwner(); if (contextOwner == null) { Log.e(TAG, "No context owner available while fetching: " + uri); return null; } String host = uri.getHost(); if (!contextOwner.mAuthorities.contains(host)) { Log.e(TAG, "Uri points to an authority not handled by the current context owner: " + uri + " (valid authorities: " + contextOwner.mAuthorities + ")"); return null; } // Add user to URI to make the request to the right instance of content provider // (see ContentProvider#getUserIdFromAuthority()). int userId = UserHandle.getUserId(contextOwner.mUid); Uri filteredUid = uri.buildUpon().encodedAuthority(userId + "@" + host).build(); // Fetch the bitmap if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Requesting bitmap: " + uri); } ParcelFileDescriptor fileDesc = getContentResolver() .openFileDescriptor(filteredUid, "r"); if (fileDesc != null) { Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fileDesc.getFileDescriptor()); fileDesc.close(); return bitmap; } else { Log.e(TAG, "Failed to create pipe for uri string: " + uri); } } catch (Throwable e) { Log.e(TAG, "Unable to fetch uri: " + uri, e); } return null; } /** * See {@link #getBitmap(Uri, int, int, float)} * * @throws IllegalArgumentException if {@code width} or {@code height} is not greater than 0. */ @Nullable public Bitmap getBitmap(Uri uri, int width, int height) { return getBitmap(uri, width, height, 1f); } /** * Fetches a bitmap from the navigation context owner (application holding navigation focus) * of the given width and height and off lane opacity. The fetched bitmaps are cached. * It returns null if: *
    *
  • there is no navigation context owner *
  • or if the {@link Uri} is invalid *
  • or if it references a process other than the current navigation context owner *
* This is a costly operation. Returned bitmaps should be fetched on a secondary thread. * * @throws IllegalArgumentException if width, height <= 0, or 0 > offLanesAlpha > 1 * @hide */ @Nullable public Bitmap getBitmap(Uri uri, int width, int height, float offLanesAlpha) { if (width <= 0 || height <= 0) { throw new IllegalArgumentException("Width and height must be > 0"); } if (offLanesAlpha < 0 || offLanesAlpha > 1) { throw new IllegalArgumentException("offLanesAlpha must be between [0, 1]"); } try { ContextOwner contextOwner = getNavigationContextOwner(); if (contextOwner == null) { Log.e(TAG, "No context owner available while fetching: " + uri); return null; } uri = uri.buildUpon() .appendQueryParameter(BITMAP_QUERY_WIDTH, String.valueOf(width)) .appendQueryParameter(BITMAP_QUERY_HEIGHT, String.valueOf(height)) .appendQueryParameter(BITMAP_QUERY_OFFLANESALPHA, String.valueOf(offLanesAlpha)) .build(); String host = uri.getHost(); if (!contextOwner.mAuthorities.contains(host)) { Log.e(TAG, "Uri points to an authority not handled by the current context owner: " + uri + " (valid authorities: " + contextOwner.mAuthorities + ")"); return null; } // Add user to URI to make the request to the right instance of content provider // (see ContentProvider#getUserIdFromAuthority()). int userId = UserHandle.getUserId(contextOwner.mUid); Uri filteredUid = uri.buildUpon().encodedAuthority(userId + "@" + host).build(); Bitmap bitmap = mCache.get(uri.toString()); if (bitmap == null) { // Fetch the bitmap if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Requesting bitmap: " + uri); } ParcelFileDescriptor fileDesc = getContentResolver() .openFileDescriptor(filteredUid, "r"); if (fileDesc != null) { bitmap = BitmapFactory.decodeFileDescriptor(fileDesc.getFileDescriptor()); fileDesc.close(); return bitmap; } else { Log.e(TAG, "Failed to create pipe for uri string: " + uri); } if (bitmap.getWidth() != width || bitmap.getHeight() != height) { bitmap = Bitmap.createScaledBitmap(bitmap, width, height, true); } mCache.put(uri.toString(), bitmap); } return bitmap; } catch (Throwable e) { Log.e(TAG, "Unable to fetch uri: " + uri, e); } return null; } }