Merge "Fixing multi-user image fetching and implementing async fetch"
diff --git a/car-lib/api/system-current.txt b/car-lib/api/system-current.txt
index 185c57f..3459e1f 100644
--- a/car-lib/api/system-current.txt
+++ b/car-lib/api/system-current.txt
@@ -169,6 +169,7 @@
 
   public abstract class InstrumentClusterRenderingService extends android.app.Service {
     ctor public InstrumentClusterRenderingService();
+    method @Nullable public android.graphics.Bitmap getBitmap(android.net.Uri);
     method @MainThread @Nullable public abstract android.car.cluster.renderer.NavigationRenderer getNavigationRenderer();
     method @CallSuper public android.os.IBinder onBind(android.content.Intent);
     method @MainThread public void onKeyEvent(@NonNull android.view.KeyEvent);
diff --git a/car-lib/src/android/car/cluster/renderer/InstrumentClusterRenderingService.java b/car-lib/src/android/car/cluster/renderer/InstrumentClusterRenderingService.java
index 4a9e5f5..f00d6ff 100644
--- a/car-lib/src/android/car/cluster/renderer/InstrumentClusterRenderingService.java
+++ b/car-lib/src/android/car/cluster/renderer/InstrumentClusterRenderingService.java
@@ -32,11 +32,16 @@
 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;
@@ -47,14 +52,19 @@
 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 that used for interaction between Car Service and Instrument Cluster. Car Service may
+ * 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()}.
  *
@@ -85,15 +95,45 @@
     private static class ContextOwner {
         final int mUid;
         final int mPid;
+        final Set<String> mPackageNames;
+        final Set<String> mAuthorities;
 
-        ContextOwner(int uid, int pid) {
+        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 + "}";
+            return "{uid: " + mUid + ", pid: " + mPid + ", packagenames: " + mPackageNames
+                    + ", authorities: " + mAuthorities + "}";
+        }
+
+        private List<String> 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();
+            }
         }
     }
 
@@ -199,7 +239,7 @@
      */
     @Nullable
     private ComponentName getNavigationComponentByOwner(ContextOwner contextOwner) {
-        for (String packageName : getPackageNamesForUid(contextOwner)) {
+        for (String packageName : contextOwner.mPackageNames) {
             ComponentName component = getComponentFromPackage(packageName);
             if (component != null) {
                 if (Log.isLoggable(TAG, Log.DEBUG)) {
@@ -211,14 +251,6 @@
         return null;
     }
 
-    private String[] getPackageNamesForUid(ContextOwner contextOwner) {
-        if (contextOwner == null || contextOwner.mUid == 0 || contextOwner.mPid == 0) {
-            return new String[0];
-        }
-        String[] packageNames  = getPackageManager().getPackagesForUid(contextOwner.mUid);
-        return packageNames != null ? packageNames : new String[0];
-    }
-
     private ContextOwner getNavigationContextOwner() {
         synchronized (mLock) {
             return mNavContextOwner;
@@ -338,8 +370,7 @@
         writer.println("activity options: " + mActivityOptions);
         writer.println("activity state: " + mActivityState);
         writer.println("current nav component: " + mNavigationComponent);
-        writer.println("current nav packages: " + Arrays.toString(getPackageNamesForUid(
-                getNavigationContextOwner())));
+        writer.println("current nav packages: " + getNavigationContextOwner().mPackageNames);
     }
 
     private class RendererBinder extends IInstrumentCluster.Stub {
@@ -356,8 +387,11 @@
 
         @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);
+                mNavContextOwner = new ContextOwner(uid, pid, getPackageManager());
             }
             mUiHandler.post(InstrumentClusterRenderingService.this::updateNavigationActivity);
         }
@@ -419,4 +453,57 @@
         }
         return result.get();
     }
+
+    /**
+     * Fetches a bitmap from the navigation context owner (application holding navigation focus).
+     * It returns null if:
+     * <ul>
+     * <li>there is no navigation context owner
+     * <li>or if the {@link Uri} is invalid
+     * <li>or if it references a process other than the current navigation context owner
+     * </ul>
+     * This is a costly operation. Returned bitmaps should be cached and fetching should be done on
+     * a secondary thread.
+     */
+    @Nullable
+    public Bitmap getBitmap(Uri uri) {
+        try {
+            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;
+    }
 }
diff --git a/car-lib/src/android/car/cluster/renderer/NavigationRenderer.java b/car-lib/src/android/car/cluster/renderer/NavigationRenderer.java
index 4681a8b..644e17e 100644
--- a/car-lib/src/android/car/cluster/renderer/NavigationRenderer.java
+++ b/car-lib/src/android/car/cluster/renderer/NavigationRenderer.java
@@ -18,7 +18,6 @@
 import android.annotation.SystemApi;
 import android.annotation.UiThread;
 import android.car.navigation.CarNavigationInstrumentCluster;
-import android.graphics.Bitmap;
 import android.os.Bundle;
 
 /**
@@ -32,10 +31,10 @@
     /**
      * Returns properties of instrument cluster for navigation.
      */
-    abstract public CarNavigationInstrumentCluster getNavigationProperties();
+    public abstract CarNavigationInstrumentCluster getNavigationProperties();
 
     /**
      * Called when an event is fired to change the navigation state.
      */
-    abstract public void onEvent(int eventType, Bundle bundle);
+    public abstract void onEvent(int eventType, Bundle bundle);
 }
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
index 84d67b9..e3ee05a 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
@@ -27,12 +27,9 @@
 import android.content.Intent;
 import android.graphics.Rect;
 import android.hardware.display.DisplayManager.DisplayListener;
+import android.os.Binder;
 import android.os.Bundle;
-import android.os.Handler;
 import android.os.IBinder;
-import android.os.Message;
-import android.os.Messenger;
-import android.os.RemoteException;
 import android.os.SystemClock;
 import android.os.UserHandle;
 import android.provider.Settings;
@@ -47,37 +44,41 @@
 
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
-import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.function.Consumer;
 
 /**
  * Implementation of {@link InstrumentClusterRenderingService} which renders an activity on a
  * virtual display that is transmitted to an external screen.
  */
-public class ClusterRenderingServiceImpl extends InstrumentClusterRenderingService {
+public class ClusterRenderingServiceImpl extends InstrumentClusterRenderingService implements
+        ImageResolver.BitmapFetcher {
     private static final String TAG = "Cluster.SampleService";
 
     private static final int NO_DISPLAY = -1;
 
+    static final int NAV_STATE_EVENT_ID = 1;
     static final String LOCAL_BINDING_ACTION = "local";
     static final String NAV_STATE_BUNDLE_KEY = "navstate";
-    static final int NAV_STATE_EVENT_ID = 1;
-    static final int MSG_SET_ACTIVITY_LAUNCH_OPTIONS = 1;
-    static final int MSG_ON_NAVIGATION_STATE_CHANGED = 2;
-    static final int MSG_ON_KEY_EVENT = 3;
-    static final int MSG_REGISTER_CLIENT = 4;
-    static final int MSG_UNREGISTER_CLIENT = 5;
-    static final String MSG_KEY_CATEGORY = "category";
-    static final String MSG_KEY_ACTIVITY_DISPLAY_ID = "activity_display_id";
-    static final String MSG_KEY_ACTIVITY_STATE = "activity_state";
-    static final String MSG_KEY_KEY_EVENT = "key_event";
 
-    private List<Messenger> mClients = new ArrayList<>();
+    private List<ServiceClient> mClients = new ArrayList<>();
     private ClusterDisplayProvider mDisplayProvider;
     private int mDisplayId = NO_DISPLAY;
-    private final IBinder mLocalBinder = new Messenger(new MessageHandler(this)).getBinder();
+    private final IBinder mLocalBinder = new LocalBinder();
+    private final ImageResolver mImageResolver = new ImageResolver(this);
+
+    public interface ServiceClient {
+        void onKeyEvent(KeyEvent keyEvent);
+        void onNavigationStateChange(NavigationState navState);
+    }
+
+    public class LocalBinder extends Binder {
+        ClusterRenderingServiceImpl getService() {
+            return ClusterRenderingServiceImpl.this;
+        }
+    }
 
     private final DisplayListener mDisplayListener = new DisplayListener() {
         @Override
@@ -98,43 +99,33 @@
         }
     };
 
-    private static class MessageHandler extends Handler {
-        private final WeakReference<ClusterRenderingServiceImpl> mService;
-
-        MessageHandler(ClusterRenderingServiceImpl service) {
-            mService = new WeakReference<>(service);
+    public void setActivityLaunchOptions(int displayId, ClusterActivityState state) {
+        ActivityOptions options = displayId != Display.INVALID_DISPLAY
+                ? ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
+                : null;
+        setClusterActivityLaunchOptions(options);
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, String.format("activity options set: %s (displayeId: %d)",
+                    options, options.getLaunchDisplayId()));
         }
-
-        @Override
-        public void handleMessage(Message msg) {
-            Log.d(TAG, "handleMessage: " + msg.what);
-            switch (msg.what) {
-                case MSG_SET_ACTIVITY_LAUNCH_OPTIONS: {
-                    int displayId = msg.getData().getInt(MSG_KEY_ACTIVITY_DISPLAY_ID);
-                    Bundle state = msg.getData().getBundle(MSG_KEY_ACTIVITY_STATE);
-                    String category = msg.getData().getString(MSG_KEY_CATEGORY);
-                    ActivityOptions options = displayId != Display.INVALID_DISPLAY
-                            ? ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
-                            : null;
-                    mService.get().setClusterActivityLaunchOptions(category, options);
-                    Log.d(TAG, String.format("activity options set: %s = %s (displayeId: %d)",
-                            category, options, options.getLaunchDisplayId()));
-                    mService.get().setClusterActivityState(category, state);
-                    Log.d(TAG, String.format("activity state set: %s = %s", category, state));
-                    break;
-                }
-                case MSG_REGISTER_CLIENT:
-                    mService.get().mClients.add(msg.replyTo);
-                    break;
-                case MSG_UNREGISTER_CLIENT:
-                    mService.get().mClients.remove(msg.replyTo);
-                    break;
-                default:
-                    super.handleMessage(msg);
-            }
+        setClusterActivityState(state);
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, String.format("activity state set: %s", state));
         }
     }
 
+    public void registerClient(ServiceClient client) {
+        mClients.add(client);
+    }
+
+    public void unregisterClient(ServiceClient client) {
+        mClients.remove(client);
+    }
+
+    public ImageResolver getImageResolver() {
+        return mImageResolver;
+    }
+
     @Override
     public IBinder onBind(Intent intent) {
         Log.d(TAG, "onBind, intent: " + intent);
@@ -161,30 +152,20 @@
 
     @Override
     public void onKeyEvent(KeyEvent keyEvent) {
-        Log.d(TAG, "onKeyEvent, keyEvent: " + keyEvent);
-        Bundle data = new Bundle();
-        data.putParcelable(MSG_KEY_KEY_EVENT, keyEvent);
-        broadcastClientMessage(MSG_ON_KEY_EVENT, data);
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, "onKeyEvent, keyEvent: " + keyEvent);
+        }
+        broadcastClientEvent(client -> client.onKeyEvent(keyEvent));
     }
 
     /**
-     * Broadcasts a message to all the registered service clients
+     * Broadcasts an event to all the registered service clients
      *
-     * @param what event identifier
-     * @param data event data
+     * @param event event to broadcast
      */
-    private void broadcastClientMessage(int what, Bundle data) {
-        Log.d(TAG, "broadcast message " + what + " to " + mClients.size() + " clients");
-        for (int i = mClients.size() - 1; i >= 0; i--) {
-            Messenger client = mClients.get(i);
-            try {
-                Message msg = Message.obtain(null, what);
-                msg.setData(data);
-                client.send(msg);
-            } catch (RemoteException ex) {
-                Log.e(TAG, "Client " + i + " is dead", ex);
-                mClients.remove(i);
-            }
+    private void broadcastClientEvent(Consumer<ServiceClient> event) {
+        for (ServiceClient client : mClients) {
+            event.accept(client);
         }
     }
 
@@ -210,7 +191,7 @@
                         bundleSummary.append(navState.toString());
 
                         // Update clients
-                        broadcastClientMessage(MSG_ON_NAVIGATION_STATE_CHANGED, bundle);
+                        broadcastClientEvent(client -> client.onNavigationStateChange(navState));
                     } else {
                         for (String key : bundle.keySet()) {
                             bundleSummary.append(key);
@@ -222,9 +203,8 @@
                     Log.d(TAG, "onEvent(" + eventType + ", " + bundleSummary + ")");
                 } catch (Exception e) {
                     Log.e(TAG, "Error parsing event data (" + eventType + ", " + bundle + ")", e);
-                    bundle.putParcelable(NAV_STATE_BUNDLE_KEY, new NavigationState.Builder().build()
-                            .toParcelable());
-                    broadcastClientMessage(MSG_ON_NAVIGATION_STATE_CHANGED, bundle);
+                    NavigationState navState = new NavigationState.Builder().build();
+                    broadcastClientEvent(client -> client.onNavigationStateChange(navState));
                 }
             }
         };
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/CueView.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/CueView.java
index e0d0d12..ae86850 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/CueView.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/CueView.java
@@ -18,19 +18,34 @@
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.drawable.BitmapDrawable;
+import android.os.Handler;
 import android.text.SpannableStringBuilder;
 import android.text.style.ImageSpan;
 import android.util.AttributeSet;
+import android.util.Log;
 import android.widget.TextView;
 
+import androidx.car.cluster.navigation.ImageReference;
 import androidx.car.cluster.navigation.RichText;
 import androidx.car.cluster.navigation.RichTextElement;
 
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
 /**
  * View component that displays the Cue information on the instrument cluster display
  */
 public class CueView extends TextView {
+    private static final String TAG = "Cluster.CueView";
+
     private String mImageSpanText;
+    private CompletableFuture<?> mFuture;
+    private Handler mHandler = new Handler();
+    private RichText mContent;
 
     public CueView(Context context) {
         super(context);
@@ -45,20 +60,45 @@
         mImageSpanText = context.getString(R.string.span_image);
     }
 
-    public void setRichText(RichText richText) {
+    public void setRichText(RichText richText, ImageResolver imageResolver) {
         if (richText == null) {
             setText(null);
             return;
         }
 
+        if (mFuture != null && !Objects.equals(richText, mContent)) {
+            mFuture.cancel(true);
+        }
+
+        List<ImageReference> imageReferences = richText.getElements().stream()
+                .filter(element -> element.getImage() != null)
+                .map(element -> element.getImage())
+                .collect(Collectors.toList());
+        mFuture = imageResolver
+                .getBitmaps(imageReferences, 0, getLineHeight())
+                .thenAccept(bitmaps -> {
+                    mHandler.post(() -> update(richText, bitmaps));
+                    mFuture = null;
+                })
+                .exceptionally(ex -> {
+                    if (Log.isLoggable(TAG, Log.DEBUG)) {
+                        Log.d(TAG, "Unable to fetch images for cue: " + richText);
+                    }
+                    mHandler.post(() -> update(richText, Collections.emptyMap()));
+                    return null;
+                });
+        mContent = richText;
+    }
+
+    private void update(RichText richText, Map<ImageReference, Bitmap> bitmaps) {
         SpannableStringBuilder builder = new SpannableStringBuilder();
+
         for (RichTextElement element : richText.getElements()) {
             if (builder.length() > 0) {
                 builder.append(" ");
             }
             if (element.getImage() != null) {
-                Bitmap bitmap = ImageResolver.getInstance().getBitmapConstrained(getContext(),
-                        element.getImage(), 0, getLineHeight());
+                Bitmap bitmap = bitmaps.get(element.getImage());
                 if (bitmap != null) {
                     String imageText = element.getText().isEmpty() ? mImageSpanText :
                             element.getText();
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ImageResolver.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ImageResolver.java
index f306143..5e03b9b 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ImageResolver.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ImageResolver.java
@@ -15,95 +15,126 @@
  */
 package android.car.cluster.sample;
 
-import android.content.ContentResolver;
-import android.content.Context;
 import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
 import android.graphics.Point;
 import android.net.Uri;
-import android.os.ParcelFileDescriptor;
 import android.util.Log;
+import android.util.LruCache;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.car.cluster.navigation.ImageReference;
 
-import java.io.FileNotFoundException;
-import java.io.IOException;
-
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
 
 /**
  * Class for retrieving bitmap images from a ContentProvider
  */
 public class ImageResolver {
     private static final String TAG = "Cluster.ImageResolver";
+    private static final int IMAGE_CACHE_SIZE_BYTES = 4 * 1024 * 1024; /* 4 mb */
 
-    private static ImageResolver sImageResolver = new ImageResolver();
+    private final BitmapFetcher mFetcher;
+    private final LruCache<String, Bitmap> mCache = new LruCache<String, Bitmap>(
+            IMAGE_CACHE_SIZE_BYTES) {
+        @Override
+        protected int sizeOf(String key, Bitmap value) {
+            return value.getByteCount();
+        }
+    };
 
-    private ImageResolver() {}
-
-    public static ImageResolver getInstance() {
-        return sImageResolver;
+    public interface BitmapFetcher {
+        Bitmap getBitmap(Uri uri);
     }
 
     /**
-     * Returns a bitmap from an URI string from a content provider
-     *
-     * @param context View context
+     * Creates a resolver that delegate the image retrieval to the given fetcher.
      */
-    @Nullable
-    public Bitmap getBitmap(Context context, Uri uri) {
-        if (Log.isLoggable(TAG, Log.DEBUG)) {
-            Log.d(TAG, "Requesting: " + uri);
-        }
-        try {
-            ContentResolver contentResolver = context.getContentResolver();
-            ParcelFileDescriptor fileDesc = contentResolver.openFileDescriptor(uri, "r");
-            if (fileDesc != null) {
-                Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fileDesc.getFileDescriptor());
-                fileDesc.close();
-                return bitmap;
-            } else {
-                Log.e(TAG, "Null pointer: Failed to create pipe for uri string: " + uri);
-            }
-        } catch (FileNotFoundException e) {
-            Log.e(TAG, "File not found for uri string: " + uri, e);
-        } catch (IOException e) {
-            Log.e(TAG, "File descriptor could not close: ", e);
-        }
-
-        return null;
+    public ImageResolver(BitmapFetcher fetcher) {
+        mFetcher = fetcher;
     }
 
     /**
-     * Returns a bitmap from a Car Instrument Cluster {@link ImageReference} that would fit inside
-     * the provided size. Either width, height or both should be greater than 0.
+     * Returns a {@link CompletableFuture} that provides a bitmap from a {@link ImageReference}.
+     * This image would fit inside the provided size. Either width, height or both should be greater
+     * than 0.
      *
-     * @param context View context
      * @param width required width, or 0 if width is flexible based on height.
      * @param height required height, or 0 if height is flexible based on width.
      */
-    @Nullable
-    public Bitmap getBitmapConstrained(Context context, ImageReference img, int width,
-            int height) {
+    @NonNull
+    public CompletableFuture<Bitmap> getBitmap(@NonNull ImageReference img, int width, int height) {
         if (Log.isLoggable(TAG, Log.DEBUG)) {
             Log.d(TAG, String.format("Requesting image %s (width: %d, height: %d)",
                     img.getRawContentUri(), width, height));
         }
 
-        // Adjust the size to fit in the requested box.
-        Point adjusted = getAdjustedSize(img.getOriginalWidth(), img.getOriginalHeight(), width,
-                height);
-        if (adjusted == null) {
-            Log.e(TAG, "The provided image has no original size: " + img.getRawContentUri());
-            return null;
-        }
-        Bitmap bitmap = getBitmap(context, img.getContentUri(adjusted.x, adjusted.y));
-        if (Log.isLoggable(TAG, Log.DEBUG)) {
-            Log.d(TAG, String.format("Returning image %s (width: %d, height: %d)",
-                    img.getRawContentUri(), width, height));
-        }
-        return bitmap != null ? Bitmap.createScaledBitmap(bitmap, adjusted.x, adjusted.y, true)
-                : null;
+        return CompletableFuture.supplyAsync(() -> {
+            // Adjust the size to fit in the requested box.
+            Point adjusted = getAdjustedSize(img.getOriginalWidth(), img.getOriginalHeight(), width,
+                    height);
+            if (adjusted == null) {
+                Log.e(TAG, "The provided image has no original size: " + img.getRawContentUri());
+                return null;
+            }
+            Uri uri = img.getContentUri(adjusted.x, adjusted.y);
+            Bitmap bitmap = mCache.get(uri.toString());
+            if (bitmap == null) {
+                bitmap = mFetcher.getBitmap(uri);
+                if (bitmap == null) {
+                    if (Log.isLoggable(TAG, Log.DEBUG)) {
+                        Log.d(TAG, "Unable to fetch image: " + uri);
+                    }
+                    return null;
+                }
+                if (bitmap.getWidth() != adjusted.x || bitmap.getHeight() != adjusted.y) {
+                    bitmap = Bitmap.createScaledBitmap(bitmap, adjusted.x, adjusted.y, true);
+                }
+                mCache.put(uri.toString(), bitmap);
+            }
+            if (Log.isLoggable(TAG, Log.DEBUG)) {
+                Log.d(TAG, String.format("Returning image %s (width: %d, height: %d)",
+                        img.getRawContentUri(), width, height));
+            }
+            return bitmap != null ? Bitmap.createScaledBitmap(bitmap, adjusted.x, adjusted.y, true)
+                    : null;
+        });
+    }
+
+    /**
+     * Same as {@link #getBitmap(ImageReference, int, int)} but it works on a list of images. The
+     * returning {@link CompletableFuture} will contain a map from each {@link ImageReference} to
+     * its bitmap. If any image fails to be fetched, the whole future completes exceptionally.
+     *
+     * @param width required width, or 0 if width is flexible based on height.
+     * @param height required height, or 0 if height is flexible based on width.
+     */
+    @NonNull
+    public CompletableFuture<Map<ImageReference, Bitmap>> getBitmaps(
+            @NonNull List<ImageReference> imgs, int width, int height) {
+        CompletableFuture<Map<ImageReference, Bitmap>> future = new CompletableFuture<>();
+
+        Map<ImageReference, CompletableFuture<Bitmap>> bitmapFutures = imgs.stream().collect(
+                Collectors.toMap(
+                        img -> img,
+                        img -> getBitmap(img, width, height)));
+
+        CompletableFuture.allOf(bitmapFutures.values().toArray(new CompletableFuture[0]))
+                .thenAccept(v -> {
+                    Map<ImageReference, Bitmap> bitmaps = bitmapFutures.entrySet().stream()
+                            .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry
+                                    .getValue().join()));
+                    future.complete(bitmaps);
+                })
+                .exceptionally(ex -> {
+                    future.completeExceptionally(ex);
+                    return null;
+                });
+
+        return future;
     }
 
     /**
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
index 3181b1e..13a1399 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
@@ -16,17 +16,6 @@
 package android.car.cluster.sample;
 
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.LOCAL_BINDING_ACTION;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_ACTIVITY_DISPLAY_ID;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_ACTIVITY_STATE;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_CATEGORY;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_KEY_EVENT;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_ON_KEY_EVENT;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl
-        .MSG_ON_NAVIGATION_STATE_CHANGED;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_REGISTER_CLIENT;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl
-        .MSG_SET_ACTIVITY_LAUNCH_OPTIONS;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_UNREGISTER_CLIENT;
 import static android.content.Intent.ACTION_USER_SWITCHED;
 import static android.content.Intent.ACTION_USER_UNLOCKED;
 
@@ -48,9 +37,6 @@
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
-import android.os.Message;
-import android.os.Messenger;
-import android.os.RemoteException;
 import android.os.UserHandle;
 import android.util.Log;
 import android.util.SparseArray;
@@ -69,7 +55,6 @@
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.ViewModelProviders;
-import androidx.versionedparcelable.ParcelUtils;
 import androidx.viewpager.widget.ViewPager;
 
 import java.lang.ref.WeakReference;
@@ -95,7 +80,8 @@
  * This is necessary because the navigation app runs under a normal user, and different users will
  * see different instances of the same application, with their own personalized data.
  */
-public class MainClusterActivity extends FragmentActivity {
+public class MainClusterActivity extends FragmentActivity implements
+        ClusterRenderingServiceImpl.ServiceClient {
     private static final String TAG = "Cluster.MainActivity";
 
     private static final NavigationState NULL_NAV_STATE = new NavigationState.Builder().build();
@@ -110,8 +96,7 @@
 
     private Map<Sensors.Gear, View> mGearsToIcon = new HashMap<>();
     private InputMethodManager mInputMethodManager;
-    private Messenger mService;
-    private Messenger mServiceCallbacks = new Messenger(new MessageHandler(this));
+    private ClusterRenderingServiceImpl mService;
     private VirtualDisplay mPendingVirtualDisplay = null;
 
     private static final int NAVIGATION_ACTIVITY_RETRY_INTERVAL_MS = 1000;
@@ -142,7 +127,7 @@
                 @Override
                 public void onFocusChange(View v, boolean hasFocus) {
                     if (hasFocus) {
-                        mPager.setCurrentItem(mButtonToFacet.get(v).order);
+                        mPager.setCurrentItem(mButtonToFacet.get(v).mOrder);
                     }
                 }
             };
@@ -151,8 +136,9 @@
         @Override
         public void onServiceConnected(ComponentName name, IBinder service) {
             Log.i(TAG, "onServiceConnected, name: " + name + ", service: " + service);
-            mService = new Messenger(service);
-            sendServiceMessage(MSG_REGISTER_CLIENT, null, mServiceCallbacks);
+            mService = ((ClusterRenderingServiceImpl.LocalBinder) service).getService();
+            mService.registerClient(MainClusterActivity.this);
+            mNavStateController.setImageResolver(mService.getImageResolver());
             if (mPendingVirtualDisplay != null) {
                 // If haven't reported the virtual display yet, do so on service connect.
                 reportNavDisplay(mPendingVirtualDisplay);
@@ -164,44 +150,11 @@
         public void onServiceDisconnected(ComponentName name) {
             Log.i(TAG, "onServiceDisconnected, name: " + name);
             mService = null;
+            mNavStateController.setImageResolver(null);
             onNavigationStateChange(NULL_NAV_STATE);
         }
     };
 
-    private static class MessageHandler extends Handler {
-        private final WeakReference<MainClusterActivity> mActivity;
-
-        MessageHandler(MainClusterActivity activity) {
-            mActivity = new WeakReference<>(activity);
-        }
-
-        @Override
-        public void handleMessage(Message msg) {
-            Bundle data = msg.getData();
-            switch (msg.what) {
-                case MSG_ON_KEY_EVENT:
-                    KeyEvent event = data.getParcelable(MSG_KEY_KEY_EVENT);
-                    if (event != null) {
-                        mActivity.get().onKeyEvent(event);
-                    }
-                    break;
-                case MSG_ON_NAVIGATION_STATE_CHANGED:
-                    if (data == null) {
-                        mActivity.get().onNavigationStateChange(null);
-                    } else {
-                        data.setClassLoader(ParcelUtils.class.getClassLoader());
-                        NavigationState navState = NavigationState
-                                .fromParcelable(data.getParcelable(
-                                        ClusterRenderingServiceImpl.NAV_STATE_BUNDLE_KEY));
-                        mActivity.get().onNavigationStateChange(navState);
-                    }
-                    break;
-                default:
-                    super.handleMessage(msg);
-            }
-        }
-    }
-
     private ActivityMonitor.ActivityListener mNavigationActivityMonitor = (displayId, activity) -> {
         if (displayId != mNavigationDisplayId) {
             return;
@@ -259,7 +212,7 @@
 
         mPager = findViewById(R.id.pager);
         mPager.setAdapter(new ClusterPageAdapter(getSupportFragmentManager()));
-        mOrderToFacet.get(0).button.requestFocus();
+        mOrderToFacet.get(0).mButton.requestFocus();
         mNavStateController = new NavStateController(findViewById(R.id.navigation_state));
 
         mClusterViewModel = ViewModelProviders.of(this).get(ClusterViewModel.class);
@@ -300,13 +253,14 @@
         mUserReceiver.unregister(this);
         mActivityMonitor.stop();
         if (mService != null) {
-            sendServiceMessage(MSG_UNREGISTER_CLIENT, null, mServiceCallbacks);
+            mService.unregisterClient(this);
             mService = null;
         }
         unbindService(mClusterRenderingServiceConnection);
     }
 
-    private void onKeyEvent(KeyEvent event) {
+    @Override
+    public void onKeyEvent(KeyEvent event) {
         Log.i(TAG, "onKeyEvent, event: " + event);
 
         // This is a hack. We use SOURCE_CLASS_POINTER here because this type of input is associated
@@ -316,7 +270,8 @@
         mInputMethodManager.dispatchKeyEventFromInputMethod(getCurrentFocus(), event);
     }
 
-    private void onNavigationStateChange(NavigationState state) {
+    @Override
+    public void onNavigationStateChange(NavigationState state) {
         Log.d(TAG, "onNavigationStateChange: " + state);
         if (mNavStateController != null) {
             mNavStateController.update(state);
@@ -338,32 +293,9 @@
     }
 
     private void reportNavDisplay(VirtualDisplay virtualDisplay) {
-        Bundle data = new Bundle();
-        data.putString(MSG_KEY_CATEGORY, Car.CAR_CATEGORY_NAVIGATION);
-        data.putInt(MSG_KEY_ACTIVITY_DISPLAY_ID, virtualDisplay.mDisplayId);
-        data.putBundle(MSG_KEY_ACTIVITY_STATE, ClusterActivityState
+        mService.setActivityLaunchOptions(virtualDisplay.mDisplayId, ClusterActivityState
                 .create(virtualDisplay.mDisplayId != Display.INVALID_DISPLAY,
-                        virtualDisplay.mUnobscuredBounds)
-                .toBundle());
-        sendServiceMessage(MSG_SET_ACTIVITY_LAUNCH_OPTIONS, data, null);
-    }
-
-    /**
-     * Sends a message to the {@link ClusterRenderingServiceImpl}, which runs on a different
-     * process.
-     * @param what action to perform
-     * @param data action data
-     * @param replyTo {@link Messenger} where to reply back
-     */
-    private void sendServiceMessage(int what, Bundle data, Messenger replyTo) {
-        try {
-            Message message = Message.obtain(null, what);
-            message.setData(data);
-            message.replyTo = replyTo;
-            mService.send(message);
-        } catch (RemoteException ex) {
-            Log.e(TAG, "Unable to deliver message " + what + ". Service died");
-        }
+                        virtualDisplay.mUnobscuredBounds));
     }
 
     public class ClusterPageAdapter extends FragmentPagerAdapter {
@@ -383,21 +315,21 @@
     }
 
     private <T> void registerFacet(Facet<T> facet) {
-        mOrderToFacet.append(facet.order, facet);
-        mButtonToFacet.put(facet.button, facet);
+        mOrderToFacet.append(facet.mOrder, facet);
+        mButtonToFacet.put(facet.mButton, facet);
 
-        facet.button.setOnFocusChangeListener(mFacetButtonFocusListener);
+        facet.mButton.setOnFocusChangeListener(mFacetButtonFocusListener);
     }
 
     private static class Facet<T> {
-        Button button;
-        Class<T> clazz;
-        int order;
+        Button mButton;
+        Class<T> mClazz;
+        int mOrder;
 
         Facet(Button button, int order, Class<T> clazz) {
-            this.button = button;
-            this.order = order;
-            this.clazz = clazz;
+            this.mButton = button;
+            this.mOrder = order;
+            this.mClazz = clazz;
         }
 
         private Fragment mFragment;
@@ -405,8 +337,9 @@
         Fragment getOrCreateFragment() {
             if (mFragment == null) {
                 try {
-                    mFragment = (Fragment) clazz.getConstructors()[0].newInstance();
-                } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
+                    mFragment = (Fragment) mClazz.getConstructors()[0].newInstance();
+                } catch (InstantiationException | IllegalAccessException
+                        | InvocationTargetException e) {
                     throw new RuntimeException(e);
                 }
             }
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
index 0d07962..3ba31d7 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
@@ -46,6 +46,7 @@
     private TextView mEta;
     private CueView mCue;
     private Context mContext;
+    private ImageResolver mImageResolver;
 
     /**
      * Creates a controller to coordinate updates to the views displaying navigation state
@@ -63,6 +64,10 @@
         mContext = container.getContext();
     }
 
+    public void setImageResolver(@Nullable ImageResolver imageResolver) {
+        mImageResolver = imageResolver;
+    }
+
     /**
      * Updates views to reflect the provided navigation state
      */
@@ -80,7 +85,7 @@
         mEta.setTextColor(getTrafficColor(traffic));
         mManeuver.setImageDrawable(getManeuverIcon(step != null ? step.getManeuver() : null));
         mDistance.setText(formatDistance(step != null ? step.getDistance() : null));
-        mCue.setRichText(step != null ? step.getCue() : null);
+        mCue.setRichText(step != null ? step.getCue() : null, mImageResolver);
 
         if (step != null && step.getLanes().size() > 0) {
             mLane.setLanes(step.getLanes());
diff --git a/tests/DirectRenderingClusterSample/tests/robotests/src/android/car/cluster/sample/ImageResolverTest.java b/tests/DirectRenderingClusterSample/tests/robotests/src/android/car/cluster/sample/ImageResolverTest.java
index 746c0df..0d1cece 100644
--- a/tests/DirectRenderingClusterSample/tests/robotests/src/android/car/cluster/sample/ImageResolverTest.java
+++ b/tests/DirectRenderingClusterSample/tests/robotests/src/android/car/cluster/sample/ImageResolverTest.java
@@ -32,7 +32,7 @@
 
     @Before
     public void setup() {
-        mImageResolver = ImageResolver.getInstance();
+        mImageResolver = new ImageResolver((uri) -> null);
     }
 
     @Test