Apply cache and preload mechanism to inline image notifications.

Inline image will consume 3x memory due to no cache implementation.
This patch apply cache mechanism to each ExpandableNotificationRow and
preloads images before inflation task.

Bug: 77956056
Test: runtest systemui, observe memory usage by AndroidProfiler
Change-Id: I2c488b1d98ddf2d4670904ed4b3e8028c0d0172e
diff --git a/core/java/android/widget/RemoteViews.java b/core/java/android/widget/RemoteViews.java
index 7b39efe..3c361c3 100644
--- a/core/java/android/widget/RemoteViews.java
+++ b/core/java/android/widget/RemoteViews.java
@@ -3373,6 +3373,12 @@
      * @hide
      */
     public interface OnViewAppliedListener {
+        /**
+         * Callback when the RemoteView has finished inflating,
+         * but no actions have been applied yet.
+         */
+        default void onViewInflated(View v) {};
+
         void onViewApplied(View v);
 
         void onError(Exception e);
@@ -3469,6 +3475,10 @@
         @Override
         protected void onPostExecute(ViewTree viewTree) {
             if (mError == null) {
+                if (mListener != null) {
+                    mListener.onViewInflated(viewTree.mRoot);
+                }
+
                 try {
                     if (mActions != null) {
                         OnClickHandler handler = mHandler == null
diff --git a/core/java/com/android/internal/widget/ImageMessageConsumer.java b/core/java/com/android/internal/widget/ImageMessageConsumer.java
new file mode 100644
index 0000000..01613dc
--- /dev/null
+++ b/core/java/com/android/internal/widget/ImageMessageConsumer.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2018 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 com.android.internal.widget;
+
+/**
+ * An interface for the class who will use the {@link ImageResolver} to resolve images.
+ */
+public interface ImageMessageConsumer {
+    /**
+     * Set the custom {@link ImageResolver} other than {@link LocalImageResolver}.
+     * @param resolver An image resolver that has custom implementation.
+     */
+    void setImageResolver(ImageResolver resolver);
+}
diff --git a/core/java/com/android/internal/widget/ImageResolver.java b/core/java/com/android/internal/widget/ImageResolver.java
new file mode 100644
index 0000000..4588525
--- /dev/null
+++ b/core/java/com/android/internal/widget/ImageResolver.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2018 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 com.android.internal.widget;
+
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+/**
+ * An interface for image resolvers that have custom implementations like cache mechanisms.
+ */
+public interface ImageResolver {
+    /**
+     * Load an image from specified uri.
+     * @param uri Uri of the target image.
+     * @return Target image in Drawable.
+     */
+    Drawable loadImage(Uri uri);
+}
diff --git a/core/java/com/android/internal/widget/LocalImageResolver.java b/core/java/com/android/internal/widget/LocalImageResolver.java
index 71d3bb5..2302de2 100644
--- a/core/java/com/android/internal/widget/LocalImageResolver.java
+++ b/core/java/com/android/internal/widget/LocalImageResolver.java
@@ -17,7 +17,6 @@
 package com.android.internal.widget;
 
 import android.annotation.Nullable;
-import android.app.Notification;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
diff --git a/core/java/com/android/internal/widget/MessagingImageMessage.java b/core/java/com/android/internal/widget/MessagingImageMessage.java
index 607a3a9..64650a7 100644
--- a/core/java/com/android/internal/widget/MessagingImageMessage.java
+++ b/core/java/com/android/internal/widget/MessagingImageMessage.java
@@ -25,6 +25,7 @@
 import android.graphics.Canvas;
 import android.graphics.Path;
 import android.graphics.drawable.Drawable;
+import android.net.Uri;
 import android.util.AttributeSet;
 import android.util.Log;
 import android.util.Pools;
@@ -57,6 +58,7 @@
     private int mActualWidth;
     private int mActualHeight;
     private boolean mIsIsolated;
+    private ImageResolver mImageResolver;
 
     public MessagingImageMessage(@NonNull Context context) {
         this(context, null);
@@ -96,11 +98,16 @@
         MessagingMessage.super.setMessage(message);
         Drawable drawable;
         try {
-            drawable = LocalImageResolver.resolveImage(message.getDataUri(), getContext());
+            Uri uri = message.getDataUri();
+            drawable = mImageResolver != null ? mImageResolver.loadImage(uri) :
+                    LocalImageResolver.resolveImage(uri, getContext());
         } catch (IOException | SecurityException e) {
             e.printStackTrace();
             return false;
         }
+        if (drawable == null) {
+            return false;
+        }
         int intrinsicHeight = drawable.getIntrinsicHeight();
         if (intrinsicHeight == 0) {
             Log.w(TAG, "Drawable with 0 intrinsic height was returned");
@@ -114,7 +121,7 @@
     }
 
     static MessagingMessage createMessage(MessagingLayout layout,
-            Notification.MessagingStyle.Message m) {
+            Notification.MessagingStyle.Message m, ImageResolver resolver) {
         MessagingLinearLayout messagingLinearLayout = layout.getMessagingLinearLayout();
         MessagingImageMessage createdMessage = sInstancePool.acquire();
         if (createdMessage == null) {
@@ -125,6 +132,7 @@
                             false);
             createdMessage.addOnLayoutChangeListener(MessagingLayout.MESSAGING_PROPERTY_ANIMATOR);
         }
+        createdMessage.setImageResolver(resolver);
         boolean created = createdMessage.setMessage(m);
         if (!created) {
             createdMessage.recycle();
@@ -133,6 +141,10 @@
         return createdMessage;
     }
 
+    private void setImageResolver(ImageResolver resolver) {
+        mImageResolver = resolver;
+    }
+
     @Override
     protected void onDraw(Canvas canvas) {
         canvas.save();
diff --git a/core/java/com/android/internal/widget/MessagingLayout.java b/core/java/com/android/internal/widget/MessagingLayout.java
index 0f2e9c5..07d0d7d 100644
--- a/core/java/com/android/internal/widget/MessagingLayout.java
+++ b/core/java/com/android/internal/widget/MessagingLayout.java
@@ -57,7 +57,7 @@
  * messages and adapts the layout accordingly.
  */
 @RemoteViews.RemoteView
-public class MessagingLayout extends FrameLayout {
+public class MessagingLayout extends FrameLayout implements ImageMessageConsumer {
 
     private static final float COLOR_SHIFT_AMOUNT = 60;
     /**
@@ -95,6 +95,7 @@
     private Person mUser;
     private CharSequence mNameReplacement;
     private boolean mDisplayImagesAtEnd;
+    private ImageResolver mImageResolver;
 
     public MessagingLayout(@NonNull Context context) {
         super(context);
@@ -167,6 +168,11 @@
         bind(newMessages, newHistoricMessages, showSpinner);
     }
 
+    @Override
+    public void setImageResolver(ImageResolver resolver) {
+        mImageResolver = resolver;
+    }
+
     private void addRemoteInputHistoryToMessages(
             List<Notification.MessagingStyle.Message> newMessages,
             CharSequence[] remoteInputHistory) {
@@ -463,12 +469,12 @@
      */
     private List<MessagingMessage> createMessages(
             List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
-        List<MessagingMessage> result = new ArrayList<>();;
+        List<MessagingMessage> result = new ArrayList<>();
         for (int i = 0; i < newMessages.size(); i++) {
             Notification.MessagingStyle.Message m = newMessages.get(i);
             MessagingMessage message = findAndRemoveMatchingMessage(m);
             if (message == null) {
-                message = MessagingMessage.createMessage(this, m);
+                message = MessagingMessage.createMessage(this, m, mImageResolver);
             }
             message.setIsHistoric(historic);
             result.add(message);
diff --git a/core/java/com/android/internal/widget/MessagingMessage.java b/core/java/com/android/internal/widget/MessagingMessage.java
index 74d0aae..c32d370 100644
--- a/core/java/com/android/internal/widget/MessagingMessage.java
+++ b/core/java/com/android/internal/widget/MessagingMessage.java
@@ -33,9 +33,9 @@
     String IMAGE_MIME_TYPE_PREFIX = "image/";
 
     static MessagingMessage createMessage(MessagingLayout layout,
-            Notification.MessagingStyle.Message m) {
+            Notification.MessagingStyle.Message m, ImageResolver resolver) {
         if (hasImage(m) && !ActivityManager.isLowRamDeviceStatic()) {
-            return MessagingImageMessage.createMessage(layout, m);
+            return MessagingImageMessage.createMessage(layout, m, resolver);
         } else {
             return MessagingTextMessage.createMessage(layout, m);
         }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
index d4d45ea..c492208 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
@@ -328,6 +328,7 @@
     private float mTranslationWhenRemoved;
     private boolean mWasChildInGroupWhenRemoved;
     private int mNotificationColorAmbient;
+    private NotificationInlineImageResolver mImageResolver;
 
     private SystemNotificationAsyncTask mSystemNotificationAsyncTask =
             new SystemNotificationAsyncTask();
@@ -1614,6 +1615,8 @@
         mFalsingManager = FalsingManager.getInstance(context);
         mNotificationInflater = new NotificationInflater(this);
         mMenuRow = new NotificationMenuRow(mContext);
+        mImageResolver = new NotificationInlineImageResolver(context,
+                new NotificationInlineImageCache());
         initDimens();
     }
 
@@ -1650,6 +1653,10 @@
                 res.getBoolean(R.bool.config_showGroupNotificationBgWhenExpanded);
     }
 
+    NotificationInlineImageResolver getImageResolver() {
+        return mImageResolver;
+    }
+
     /**
      * Resets this view so it can be re-used for an updated notification.
      */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInflater.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInflater.java
index 70860258..ac7be21 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInflater.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInflater.java
@@ -33,6 +33,7 @@
 import android.widget.RemoteViews;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.widget.ImageMessageConsumer;
 import com.android.systemui.statusbar.InflationTask;
 import com.android.systemui.statusbar.notification.InflationException;
 import com.android.systemui.statusbar.notification.MediaNotificationProcessor;
@@ -114,7 +115,7 @@
     @InflationFlag
     private int mInflationFlags = REQUIRED_INFLATION_FLAGS;
 
-    private static final InflationExecutor EXECUTOR = new InflationExecutor();
+    static final InflationExecutor EXECUTOR = new InflationExecutor();
 
     private final ExpandableNotificationRow mRow;
     private boolean mIsLowPriority;
@@ -244,6 +245,10 @@
         // Only inflate the ones that are set.
         reInflateFlags &= mInflationFlags;
         StatusBarNotification sbn = mRow.getEntry().notification;
+
+        // To check if the notification has inline image and preload inline image if necessary.
+        mRow.getImageResolver().preloadImages(sbn.getNotification());
+
         AsyncInflationTask task = new AsyncInflationTask(sbn, reInflateFlags, mCachedContentViews,
                 mRow, mIsLowPriority, mIsChildInGroup, mUsesIncreasedHeight,
                 mUsesIncreasedHeadsUpHeight, mRedactAmbient, mCallback, mRemoteViewClickHandler);
@@ -520,8 +525,14 @@
             }
             return;
         }
-        RemoteViews.OnViewAppliedListener listener
-                = new RemoteViews.OnViewAppliedListener() {
+        RemoteViews.OnViewAppliedListener listener = new RemoteViews.OnViewAppliedListener() {
+
+            @Override
+            public void onViewInflated(View v) {
+                if (v instanceof ImageMessageConsumer) {
+                    ((ImageMessageConsumer) v).setImageResolver(row.getImageResolver());
+                }
+            }
 
             @Override
             public void onViewApplied(View v) {
@@ -851,6 +862,10 @@
             mRow.getEntry().onInflationTaskFinished();
             mRow.onNotificationUpdated();
             mCallback.onAsyncInflationFinished(mRow.getEntry(), inflatedFlags);
+
+            // Notify the resolver that the inflation task has finished,
+            // try to purge unnecessary cached entries.
+            mRow.getImageResolver().purgeCache();
         }
 
         @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageCache.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageCache.java
new file mode 100644
index 0000000..8c8bad2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageCache.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2018 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 com.android.systemui.statusbar.notification.row;
+
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.util.Log;
+
+import java.io.IOException;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * A cache for inline images of image messages.
+ */
+public class NotificationInlineImageCache implements NotificationInlineImageResolver.ImageCache {
+    private static final String TAG = NotificationInlineImageCache.class.getSimpleName();
+
+    private NotificationInlineImageResolver mResolver;
+    private final ConcurrentHashMap<Uri, PreloadImageTask> mCache;
+
+    public NotificationInlineImageCache() {
+        mCache = new ConcurrentHashMap<>();
+    }
+
+    @Override
+    public void setImageResolver(NotificationInlineImageResolver resolver) {
+        mResolver = resolver;
+    }
+
+    @Override
+    public boolean hasEntry(Uri uri) {
+        return mCache.containsKey(uri);
+    }
+
+    @Override
+    public void preload(Uri uri) {
+        PreloadImageTask newTask = new PreloadImageTask(mResolver);
+        newTask.executeOnExecutor(NotificationInflater.EXECUTOR, uri);
+        mCache.put(uri, newTask);
+    }
+
+    @Override
+    public Drawable get(Uri uri) {
+        Drawable result = null;
+        try {
+            result = mCache.get(uri).get();
+        } catch (InterruptedException | ExecutionException ex) {
+            Log.d(TAG, "get: Failed get image from " + uri);
+        }
+        return result;
+    }
+
+    @Override
+    public void purge() {
+        Set<Uri> wantedSet = mResolver.getWantedUriSet();
+        mCache.entrySet().removeIf(entry -> !wantedSet.contains(entry.getKey()));
+    }
+
+    private static class PreloadImageTask extends AsyncTask<Uri, Void, Drawable> {
+        private final NotificationInlineImageResolver mResolver;
+
+        PreloadImageTask(NotificationInlineImageResolver resolver) {
+            mResolver = resolver;
+        }
+
+        @Override
+        protected Drawable doInBackground(Uri... uris) {
+            Drawable drawable = null;
+            Uri target = uris[0];
+
+            try {
+                drawable = mResolver.resolveImage(target);
+            } catch (IOException ex) {
+                Log.d(TAG, "PreloadImageTask: Resolve failed from " + target);
+            }
+
+            return drawable;
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageResolver.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageResolver.java
new file mode 100644
index 0000000..588246f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationInlineImageResolver.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2018 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 com.android.systemui.statusbar.notification.row;
+
+import android.app.ActivityManager;
+import android.app.Notification;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.util.Log;
+
+import com.android.internal.widget.ImageResolver;
+import com.android.internal.widget.LocalImageResolver;
+import com.android.internal.widget.MessagingMessage;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Custom resolver with built-in image cache for image messages.
+ */
+public class NotificationInlineImageResolver implements ImageResolver {
+    private static final String TAG = NotificationInlineImageResolver.class.getSimpleName();
+
+    private final Context mContext;
+    private final ImageCache mImageCache;
+    private Set<Uri> mWantedUriSet;
+
+    /**
+     * Constructor.
+     * @param context    Context.
+     * @param imageCache The implementation of internal cache.
+     */
+    public NotificationInlineImageResolver(Context context, ImageCache imageCache) {
+        mContext = context.getApplicationContext();
+        mImageCache = imageCache;
+
+        if (mImageCache != null) {
+            mImageCache.setImageResolver(this);
+        }
+    }
+
+    /**
+     * Check if this resolver has its internal cache implementation.
+     * @return True if has its internal cache, false otherwise.
+     */
+    public boolean hasCache() {
+        return mImageCache != null && !ActivityManager.isLowRamDeviceStatic();
+    }
+
+    /**
+     * To resolve image from specified uri directly.
+     * @param uri Uri of the image.
+     * @return Drawable of the image.
+     * @throws IOException Throws if failed at resolving the image.
+     */
+    Drawable resolveImage(Uri uri) throws IOException {
+        return LocalImageResolver.resolveImage(uri, mContext);
+    }
+
+    @Override
+    public Drawable loadImage(Uri uri) {
+        Drawable result = null;
+        try {
+            result = hasCache() ? mImageCache.get(uri) : resolveImage(uri);
+        } catch (IOException ex) {
+            Log.d(TAG, "loadImage: Can't load image from " + uri);
+        }
+        return result;
+    }
+
+    /**
+     * Resolve the message list from specified notification and
+     * refresh internal cache according to the result.
+     * @param notification The Notification to be resolved.
+     */
+    public void preloadImages(Notification notification) {
+        if (!hasCache()) {
+            return;
+        }
+
+        retrieveWantedUriSet(notification);
+        Set<Uri> wantedSet = getWantedUriSet();
+        wantedSet.forEach(uri -> {
+            if (!mImageCache.hasEntry(uri)) {
+                // The uri is not in the cache, we need trigger a loading task for it.
+                mImageCache.preload(uri);
+            }
+        });
+    }
+
+    /**
+     * Try to purge unnecessary cache entries.
+     */
+    public void purgeCache() {
+        if (!hasCache()) {
+            return;
+        }
+        mImageCache.purge();
+    }
+
+    private void retrieveWantedUriSet(Notification notification) {
+        Parcelable[] messages;
+        Parcelable[] historicMessages;
+        List<Notification.MessagingStyle.Message> messageList;
+        List<Notification.MessagingStyle.Message> historicList;
+        Set<Uri> result = new HashSet<>();
+
+        Bundle extras = notification.extras;
+        if (extras == null) {
+            return;
+        }
+
+        messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
+        messageList = messages == null ? null :
+                Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
+        if (messageList != null) {
+            for (Notification.MessagingStyle.Message message : messageList) {
+                if (MessagingMessage.hasImage(message)) {
+                    result.add(message.getDataUri());
+                }
+            }
+        }
+
+        historicMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
+        historicList = historicMessages == null ? null :
+                Notification.MessagingStyle.Message.getMessagesFromBundleArray(historicMessages);
+        if (historicList != null) {
+            for (Notification.MessagingStyle.Message historic : historicList) {
+                if (MessagingMessage.hasImage(historic)) {
+                    result.add(historic.getDataUri());
+                }
+            }
+        }
+
+        mWantedUriSet = result;
+    }
+
+    Set<Uri> getWantedUriSet() {
+        return mWantedUriSet;
+    }
+
+    /**
+     * A interface for internal cache implementation of this resolver.
+     */
+    interface ImageCache {
+        /**
+         * Load the image from cache first then resolve from uri if missed the cache.
+         * @param uri The uri of the image.
+         * @return Drawable of the image.
+         */
+        Drawable get(Uri uri);
+
+        /**
+         * Set the image resolver that actually resolves image from specified uri.
+         * @param resolver The resolver implementation that resolves image from specified uri.
+         */
+        void setImageResolver(NotificationInlineImageResolver resolver);
+
+        /**
+         * Check if the uri is in the cache no matter it is loading or loaded.
+         * @param uri The uri to check.
+         * @return True if it is already in the cache; false otherwise.
+         */
+        boolean hasEntry(Uri uri);
+
+        /**
+         * Start a new loading task for the target uri.
+         * @param uri The target to load.
+         */
+        void preload(Uri uri);
+
+        /**
+         * Purge unnecessary entries in the cache.
+         */
+        void purge();
+    }
+
+}