/* * Copyright (C) 2017 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; import android.annotation.Nullable; import android.app.Notification; import android.content.Context; import android.os.AsyncTask; import android.os.CancellationSignal; import android.service.notification.StatusBarNotification; import android.util.Log; import android.view.View; import android.widget.RemoteViews; import com.android.internal.annotations.VisibleForTesting; import com.android.systemui.R; import com.android.systemui.statusbar.InflationTask; import com.android.systemui.statusbar.ExpandableNotificationRow; import com.android.systemui.statusbar.NotificationContentView; import com.android.systemui.statusbar.NotificationData; import com.android.systemui.statusbar.phone.StatusBar; import com.android.systemui.util.Assert; import java.util.HashMap; import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * A utility that inflates the right kind of contentView based on the state */ public class NotificationInflater { public static final String TAG = "NotificationInflater"; @VisibleForTesting static final int FLAG_REINFLATE_ALL = ~0; private static final int FLAG_REINFLATE_CONTENT_VIEW = 1<<0; @VisibleForTesting static final int FLAG_REINFLATE_EXPANDED_VIEW = 1<<1; private static final int FLAG_REINFLATE_HEADS_UP_VIEW = 1<<2; private static final int FLAG_REINFLATE_PUBLIC_VIEW = 1<<3; private static final int FLAG_REINFLATE_AMBIENT_VIEW = 1<<4; private static final InflationExecutor EXECUTOR = new InflationExecutor(); private final ExpandableNotificationRow mRow; private boolean mIsLowPriority; private boolean mUsesIncreasedHeight; private boolean mUsesIncreasedHeadsUpHeight; private RemoteViews.OnClickHandler mRemoteViewClickHandler; private boolean mIsChildInGroup; private InflationCallback mCallback; private boolean mRedactAmbient; public NotificationInflater(ExpandableNotificationRow row) { mRow = row; } public void setIsLowPriority(boolean isLowPriority) { mIsLowPriority = isLowPriority; } /** * Set whether the notification is a child in a group * * @return whether the view was re-inflated */ public void setIsChildInGroup(boolean childInGroup) { if (childInGroup != mIsChildInGroup) { mIsChildInGroup = childInGroup; if (mIsLowPriority) { int flags = FLAG_REINFLATE_CONTENT_VIEW | FLAG_REINFLATE_EXPANDED_VIEW; inflateNotificationViews(flags); } } ; } public void setUsesIncreasedHeight(boolean usesIncreasedHeight) { mUsesIncreasedHeight = usesIncreasedHeight; } public void setUsesIncreasedHeadsUpHeight(boolean usesIncreasedHeight) { mUsesIncreasedHeadsUpHeight = usesIncreasedHeight; } public void setRemoteViewClickHandler(RemoteViews.OnClickHandler remoteViewClickHandler) { mRemoteViewClickHandler = remoteViewClickHandler; } public void setRedactAmbient(boolean redactAmbient) { if (mRedactAmbient != redactAmbient) { mRedactAmbient = redactAmbient; if (mRow.getEntry() == null) { return; } inflateNotificationViews(FLAG_REINFLATE_AMBIENT_VIEW); } } /** * Inflate all views of this notification on a background thread. This is asynchronous and will * notify the callback once it's finished. */ public void inflateNotificationViews() { inflateNotificationViews(FLAG_REINFLATE_ALL); } /** * Reinflate all views for the specified flags on a background thread. This is asynchronous and * will notify the callback once it's finished. * * @param reInflateFlags flags which views should be reinflated. Use {@link #FLAG_REINFLATE_ALL} * to reinflate all of views. */ @VisibleForTesting void inflateNotificationViews(int reInflateFlags) { if (mRow.isRemoved()) { // We don't want to reinflate anything for removed notifications. Otherwise views might // be readded to the stack, leading to leaks. This may happen with low-priority groups // where the removal of already removed children can lead to a reinflation. return; } StatusBarNotification sbn = mRow.getEntry().notification; AsyncInflationTask task = new AsyncInflationTask(sbn, reInflateFlags, mRow, mIsLowPriority, mIsChildInGroup, mUsesIncreasedHeight, mUsesIncreasedHeadsUpHeight, mRedactAmbient, mCallback, mRemoteViewClickHandler); if (mCallback != null && mCallback.doInflateSynchronous()) { task.onPostExecute(task.doInBackground()); } else { task.execute(); } } @VisibleForTesting InflationProgress inflateNotificationViews(int reInflateFlags, Notification.Builder builder, Context packageContext) { InflationProgress result = createRemoteViews(reInflateFlags, builder, mIsLowPriority, mIsChildInGroup, mUsesIncreasedHeight, mUsesIncreasedHeadsUpHeight, mRedactAmbient, packageContext); apply(result, reInflateFlags, mRow, mRedactAmbient, mRemoteViewClickHandler, null); return result; } private static InflationProgress createRemoteViews(int reInflateFlags, Notification.Builder builder, boolean isLowPriority, boolean isChildInGroup, boolean usesIncreasedHeight, boolean usesIncreasedHeadsUpHeight, boolean redactAmbient, Context packageContext) { InflationProgress result = new InflationProgress(); isLowPriority = isLowPriority && !isChildInGroup; if ((reInflateFlags & FLAG_REINFLATE_CONTENT_VIEW) != 0) { result.newContentView = createContentView(builder, isLowPriority, usesIncreasedHeight); } if ((reInflateFlags & FLAG_REINFLATE_EXPANDED_VIEW) != 0) { result.newExpandedView = createExpandedView(builder, isLowPriority); } if ((reInflateFlags & FLAG_REINFLATE_HEADS_UP_VIEW) != 0) { result.newHeadsUpView = builder.createHeadsUpContentView(usesIncreasedHeadsUpHeight); } if ((reInflateFlags & FLAG_REINFLATE_PUBLIC_VIEW) != 0) { result.newPublicView = builder.makePublicContentView(); } if ((reInflateFlags & FLAG_REINFLATE_AMBIENT_VIEW) != 0) { result.newAmbientView = redactAmbient ? builder.makePublicAmbientNotification() : builder.makeAmbientNotification(); } result.packageContext = packageContext; result.headsUpStatusBarText = builder.getHeadsUpStatusBarText(false /* showingPublic */); result.headsUpStatusBarTextPublic = builder.getHeadsUpStatusBarText( true /* showingPublic */); return result; } public static CancellationSignal apply(InflationProgress result, int reInflateFlags, ExpandableNotificationRow row, boolean redactAmbient, RemoteViews.OnClickHandler remoteViewClickHandler, @Nullable InflationCallback callback) { NotificationData.Entry entry = row.getEntry(); NotificationContentView privateLayout = row.getPrivateLayout(); NotificationContentView publicLayout = row.getPublicLayout(); final HashMap runningInflations = new HashMap<>(); int flag = FLAG_REINFLATE_CONTENT_VIEW; if ((reInflateFlags & flag) != 0) { boolean isNewView = !canReapplyRemoteView(result.newContentView, entry.cachedContentView); ApplyCallback applyCallback = new ApplyCallback() { @Override public void setResultView(View v) { result.inflatedContentView = v; } @Override public RemoteViews getRemoteView() { return result.newContentView; } }; applyRemoteView(result, reInflateFlags, flag, row, redactAmbient, isNewView, remoteViewClickHandler, callback, entry, privateLayout, privateLayout.getContractedChild(), privateLayout.getVisibleWrapper( NotificationContentView.VISIBLE_TYPE_CONTRACTED), runningInflations, applyCallback); } flag = FLAG_REINFLATE_EXPANDED_VIEW; if ((reInflateFlags & flag) != 0) { if (result.newExpandedView != null) { boolean isNewView = !canReapplyRemoteView(result.newExpandedView, entry.cachedBigContentView); ApplyCallback applyCallback = new ApplyCallback() { @Override public void setResultView(View v) { result.inflatedExpandedView = v; } @Override public RemoteViews getRemoteView() { return result.newExpandedView; } }; applyRemoteView(result, reInflateFlags, flag, row, redactAmbient, isNewView, remoteViewClickHandler, callback, entry, privateLayout, privateLayout.getExpandedChild(), privateLayout.getVisibleWrapper( NotificationContentView.VISIBLE_TYPE_EXPANDED), runningInflations, applyCallback); } } flag = FLAG_REINFLATE_HEADS_UP_VIEW; if ((reInflateFlags & flag) != 0) { if (result.newHeadsUpView != null) { boolean isNewView = !canReapplyRemoteView(result.newHeadsUpView, entry.cachedHeadsUpContentView); ApplyCallback applyCallback = new ApplyCallback() { @Override public void setResultView(View v) { result.inflatedHeadsUpView = v; } @Override public RemoteViews getRemoteView() { return result.newHeadsUpView; } }; applyRemoteView(result, reInflateFlags, flag, row, redactAmbient, isNewView, remoteViewClickHandler, callback, entry, privateLayout, privateLayout.getHeadsUpChild(), privateLayout.getVisibleWrapper( NotificationContentView.VISIBLE_TYPE_HEADSUP), runningInflations, applyCallback); } } flag = FLAG_REINFLATE_PUBLIC_VIEW; if ((reInflateFlags & flag) != 0) { boolean isNewView = !canReapplyRemoteView(result.newPublicView, entry.cachedPublicContentView); ApplyCallback applyCallback = new ApplyCallback() { @Override public void setResultView(View v) { result.inflatedPublicView = v; } @Override public RemoteViews getRemoteView() { return result.newPublicView; } }; applyRemoteView(result, reInflateFlags, flag, row, redactAmbient, isNewView, remoteViewClickHandler, callback, entry, publicLayout, publicLayout.getContractedChild(), publicLayout.getVisibleWrapper(NotificationContentView.VISIBLE_TYPE_CONTRACTED), runningInflations, applyCallback); } flag = FLAG_REINFLATE_AMBIENT_VIEW; if ((reInflateFlags & flag) != 0) { NotificationContentView newParent = redactAmbient ? publicLayout : privateLayout; boolean isNewView = !canReapplyAmbient(row, redactAmbient) || !canReapplyRemoteView(result.newAmbientView, entry.cachedAmbientContentView); ApplyCallback applyCallback = new ApplyCallback() { @Override public void setResultView(View v) { result.inflatedAmbientView = v; } @Override public RemoteViews getRemoteView() { return result.newAmbientView; } }; applyRemoteView(result, reInflateFlags, flag, row, redactAmbient, isNewView, remoteViewClickHandler, callback, entry, newParent, newParent.getAmbientChild(), newParent.getVisibleWrapper( NotificationContentView.VISIBLE_TYPE_AMBIENT), runningInflations, applyCallback); } // Let's try to finish, maybe nobody is even inflating anything finishIfDone(result, reInflateFlags, runningInflations, callback, row, redactAmbient); CancellationSignal cancellationSignal = new CancellationSignal(); cancellationSignal.setOnCancelListener( () -> runningInflations.values().forEach(CancellationSignal::cancel)); return cancellationSignal; } @VisibleForTesting static void applyRemoteView(final InflationProgress result, final int reInflateFlags, int inflationId, final ExpandableNotificationRow row, final boolean redactAmbient, boolean isNewView, RemoteViews.OnClickHandler remoteViewClickHandler, @Nullable final InflationCallback callback, NotificationData.Entry entry, NotificationContentView parentLayout, View existingView, NotificationViewWrapper existingWrapper, final HashMap runningInflations, ApplyCallback applyCallback) { RemoteViews newContentView = applyCallback.getRemoteView(); if (callback != null && callback.doInflateSynchronous()) { try { if (isNewView) { View v = newContentView.apply( result.packageContext, parentLayout, remoteViewClickHandler); v.setIsRootNamespace(true); applyCallback.setResultView(v); } else { newContentView.reapply( result.packageContext, existingView, remoteViewClickHandler); existingWrapper.onReinflated(); } } catch (Exception e) { handleInflationError(runningInflations, e, entry.notification, callback); // Add a running inflation to make sure we don't trigger callbacks. // Safe to do because only happens in tests. runningInflations.put(inflationId, new CancellationSignal()); } return; } RemoteViews.OnViewAppliedListener listener = new RemoteViews.OnViewAppliedListener() { @Override public void onViewApplied(View v) { if (isNewView) { v.setIsRootNamespace(true); applyCallback.setResultView(v); } else if (existingWrapper != null) { existingWrapper.onReinflated(); } runningInflations.remove(inflationId); finishIfDone(result, reInflateFlags, runningInflations, callback, row, redactAmbient); } @Override public void onError(Exception e) { // Uh oh the async inflation failed. Due to some bugs (see b/38190555), this could // actually also be a system issue, so let's try on the UI thread again to be safe. try { View newView = existingView; if (isNewView) { newView = newContentView.apply( result.packageContext, parentLayout, remoteViewClickHandler); } else { newContentView.reapply( result.packageContext, existingView, remoteViewClickHandler); } Log.wtf(TAG, "Async Inflation failed but normal inflation finished normally.", e); onViewApplied(newView); } catch (Exception anotherException) { runningInflations.remove(inflationId); handleInflationError(runningInflations, e, entry.notification, callback); } } }; CancellationSignal cancellationSignal; if (isNewView) { cancellationSignal = newContentView.applyAsync( result.packageContext, parentLayout, EXECUTOR, listener, remoteViewClickHandler); } else { cancellationSignal = newContentView.reapplyAsync( result.packageContext, existingView, EXECUTOR, listener, remoteViewClickHandler); } runningInflations.put(inflationId, cancellationSignal); } private static void handleInflationError(HashMap runningInflations, Exception e, StatusBarNotification notification, @Nullable InflationCallback callback) { Assert.isMainThread(); runningInflations.values().forEach(CancellationSignal::cancel); if (callback != null) { callback.handleInflationException(notification, e); } } /** * Finish the inflation of the views * * @return true if the inflation was finished */ private static boolean finishIfDone(InflationProgress result, int reInflateFlags, HashMap runningInflations, @Nullable InflationCallback endListener, ExpandableNotificationRow row, boolean redactAmbient) { Assert.isMainThread(); NotificationData.Entry entry = row.getEntry(); NotificationContentView privateLayout = row.getPrivateLayout(); NotificationContentView publicLayout = row.getPublicLayout(); if (runningInflations.isEmpty()) { if ((reInflateFlags & FLAG_REINFLATE_CONTENT_VIEW) != 0) { if (result.inflatedContentView != null) { privateLayout.setContractedChild(result.inflatedContentView); } entry.cachedContentView = result.newContentView; } if ((reInflateFlags & FLAG_REINFLATE_EXPANDED_VIEW) != 0) { if (result.inflatedExpandedView != null) { privateLayout.setExpandedChild(result.inflatedExpandedView); } else if (result.newExpandedView == null) { privateLayout.setExpandedChild(null); } entry.cachedBigContentView = result.newExpandedView; row.setExpandable(result.newExpandedView != null); } if ((reInflateFlags & FLAG_REINFLATE_HEADS_UP_VIEW) != 0) { if (result.inflatedHeadsUpView != null) { privateLayout.setHeadsUpChild(result.inflatedHeadsUpView); } else if (result.newHeadsUpView == null) { privateLayout.setHeadsUpChild(null); } entry.cachedHeadsUpContentView = result.newHeadsUpView; } if ((reInflateFlags & FLAG_REINFLATE_PUBLIC_VIEW) != 0) { if (result.inflatedPublicView != null) { publicLayout.setContractedChild(result.inflatedPublicView); } entry.cachedPublicContentView = result.newPublicView; } if ((reInflateFlags & FLAG_REINFLATE_AMBIENT_VIEW) != 0) { if (result.inflatedAmbientView != null) { NotificationContentView newParent = redactAmbient ? publicLayout : privateLayout; NotificationContentView otherParent = !redactAmbient ? publicLayout : privateLayout; newParent.setAmbientChild(result.inflatedAmbientView); otherParent.setAmbientChild(null); } entry.cachedAmbientContentView = result.newAmbientView; } entry.headsUpStatusBarText = result.headsUpStatusBarText; entry.headsUpStatusBarTextPublic = result.headsUpStatusBarTextPublic; if (endListener != null) { endListener.onAsyncInflationFinished(row.getEntry()); } return true; } return false; } private static RemoteViews createExpandedView(Notification.Builder builder, boolean isLowPriority) { RemoteViews bigContentView = builder.createBigContentView(); if (bigContentView != null) { return bigContentView; } if (isLowPriority) { RemoteViews contentView = builder.createContentView(); Notification.Builder.makeHeaderExpanded(contentView); return contentView; } return null; } private static RemoteViews createContentView(Notification.Builder builder, boolean isLowPriority, boolean useLarge) { if (isLowPriority) { return builder.makeLowPriorityContentView(false /* useRegularSubtext */); } return builder.createContentView(useLarge); } /** * @param newView The new view that will be applied * @param oldView The old view that was applied to the existing view before * @return {@code true} if the RemoteViews are the same and the view can be reused to reapply. */ @VisibleForTesting static boolean canReapplyRemoteView(final RemoteViews newView, final RemoteViews oldView) { return (newView == null && oldView == null) || (newView != null && oldView != null && oldView.getPackage() != null && newView.getPackage() != null && newView.getPackage().equals(oldView.getPackage()) && newView.getLayoutId() == oldView.getLayoutId() && !oldView.isReapplyDisallowed()); } public void setInflationCallback(InflationCallback callback) { mCallback = callback; } public interface InflationCallback { void handleInflationException(StatusBarNotification notification, Exception e); void onAsyncInflationFinished(NotificationData.Entry entry); /** * Used to disable async-ness for tests. Should only be used for tests. */ default boolean doInflateSynchronous() { return false; } } public void onDensityOrFontScaleChanged() { NotificationData.Entry entry = mRow.getEntry(); entry.cachedAmbientContentView = null; entry.cachedBigContentView = null; entry.cachedContentView = null; entry.cachedHeadsUpContentView = null; entry.cachedPublicContentView = null; inflateNotificationViews(); } private static boolean canReapplyAmbient(ExpandableNotificationRow row, boolean redactAmbient) { NotificationContentView ambientView = redactAmbient ? row.getPublicLayout() : row.getPrivateLayout(); ; return ambientView.getAmbientChild() != null; } public static class AsyncInflationTask extends AsyncTask implements InflationCallback, InflationTask { private final StatusBarNotification mSbn; private final Context mContext; private final boolean mIsLowPriority; private final boolean mIsChildInGroup; private final boolean mUsesIncreasedHeight; private final InflationCallback mCallback; private final boolean mUsesIncreasedHeadsUpHeight; private final boolean mRedactAmbient; private int mReInflateFlags; private ExpandableNotificationRow mRow; private Exception mError; private RemoteViews.OnClickHandler mRemoteViewClickHandler; private CancellationSignal mCancellationSignal; private AsyncInflationTask(StatusBarNotification notification, int reInflateFlags, ExpandableNotificationRow row, boolean isLowPriority, boolean isChildInGroup, boolean usesIncreasedHeight, boolean usesIncreasedHeadsUpHeight, boolean redactAmbient, InflationCallback callback, RemoteViews.OnClickHandler remoteViewClickHandler) { mRow = row; mSbn = notification; mReInflateFlags = reInflateFlags; mContext = mRow.getContext(); mIsLowPriority = isLowPriority; mIsChildInGroup = isChildInGroup; mUsesIncreasedHeight = usesIncreasedHeight; mUsesIncreasedHeadsUpHeight = usesIncreasedHeadsUpHeight; mRedactAmbient = redactAmbient; mRemoteViewClickHandler = remoteViewClickHandler; mCallback = callback; NotificationData.Entry entry = row.getEntry(); entry.setInflationTask(this); } @VisibleForTesting public int getReInflateFlags() { return mReInflateFlags; } @Override protected InflationProgress doInBackground(Void... params) { try { final Notification.Builder recoveredBuilder = Notification.Builder.recoverBuilder(mContext, mSbn.getNotification()); Context packageContext = mSbn.getPackageContext(mContext); Notification notification = mSbn.getNotification(); if (notification.isMediaNotification()) { MediaNotificationProcessor processor = new MediaNotificationProcessor(mContext, packageContext); processor.processNotification(notification, recoveredBuilder); } return createRemoteViews(mReInflateFlags, recoveredBuilder, mIsLowPriority, mIsChildInGroup, mUsesIncreasedHeight, mUsesIncreasedHeadsUpHeight, mRedactAmbient, packageContext); } catch (Exception e) { mError = e; return null; } } @Override protected void onPostExecute(InflationProgress result) { if (mError == null) { mCancellationSignal = apply(result, mReInflateFlags, mRow, mRedactAmbient, mRemoteViewClickHandler, this); } else { handleError(mError); } } private void handleError(Exception e) { mRow.getEntry().onInflationTaskFinished(); StatusBarNotification sbn = mRow.getStatusBarNotification(); final String ident = sbn.getPackageName() + "/0x" + Integer.toHexString(sbn.getId()); Log.e(StatusBar.TAG, "couldn't inflate view for notification " + ident, e); mCallback.handleInflationException(sbn, new InflationException("Couldn't inflate contentViews" + e)); } @Override public void abort() { cancel(true /* mayInterruptIfRunning */); if (mCancellationSignal != null) { mCancellationSignal.cancel(); } } @Override public void supersedeTask(InflationTask task) { if (task instanceof AsyncInflationTask) { // We want to inflate all flags of the previous task as well mReInflateFlags |= ((AsyncInflationTask) task).mReInflateFlags; } } @Override public void handleInflationException(StatusBarNotification notification, Exception e) { handleError(e); } @Override public void onAsyncInflationFinished(NotificationData.Entry entry) { mRow.getEntry().onInflationTaskFinished(); mRow.onNotificationUpdated(); mCallback.onAsyncInflationFinished(mRow.getEntry()); } @Override public boolean doInflateSynchronous() { return mCallback != null && mCallback.doInflateSynchronous(); } } @VisibleForTesting static class InflationProgress { private RemoteViews newContentView; private RemoteViews newHeadsUpView; private RemoteViews newExpandedView; private RemoteViews newAmbientView; private RemoteViews newPublicView; @VisibleForTesting Context packageContext; private View inflatedContentView; private View inflatedHeadsUpView; private View inflatedExpandedView; private View inflatedAmbientView; private View inflatedPublicView; private CharSequence headsUpStatusBarText; private CharSequence headsUpStatusBarTextPublic; } @VisibleForTesting abstract static class ApplyCallback { public abstract void setResultView(View v); public abstract RemoteViews getRemoteView(); } /** * A custom executor that allows more tasks to be queued. Default values are copied from * AsyncTask */ private static class InflationExecutor implements Executor { private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); // We want at least 2 threads and at most 4 threads in the core pool, // preferring to have 1 less than the CPU count to avoid saturating // the CPU with background work private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4)); private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; private static final int KEEP_ALIVE_SECONDS = 30; private static final ThreadFactory sThreadFactory = new ThreadFactory() { private final AtomicInteger mCount = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(r, "InflaterThread #" + mCount.getAndIncrement()); } }; private final ThreadPoolExecutor mExecutor; private InflationExecutor() { mExecutor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), sThreadFactory); mExecutor.allowCoreThreadTimeOut(true); } @Override public void execute(Runnable runnable) { mExecutor.execute(runnable); } } }