/* * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.app; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UserIdInt; import android.graphics.drawable.Icon; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; /** * @hide */ public final class NotificationHistory implements Parcelable { /** * A historical notification. Any new fields added here should also be added to * {@link #readNotificationFromParcel} and * {@link #writeNotificationToParcel(HistoricalNotification, Parcel, int)}. */ public static final class HistoricalNotification { private String mPackage; private String mChannelName; private String mChannelId; private int mUid; private @UserIdInt int mUserId; private long mPostedTimeMs; private String mTitle; private String mText; private Icon mIcon; private String mConversationId; private HistoricalNotification() {} public String getPackage() { return mPackage; } public String getChannelName() { return mChannelName; } public String getChannelId() { return mChannelId; } public int getUid() { return mUid; } public int getUserId() { return mUserId; } public long getPostedTimeMs() { return mPostedTimeMs; } public String getTitle() { return mTitle; } public String getText() { return mText; } public Icon getIcon() { return mIcon; } public String getKey() { return mPackage + "|" + mUid + "|" + mPostedTimeMs; } public String getConversationId() { return mConversationId; } @Override public String toString() { return "HistoricalNotification{" + "key='" + getKey() + '\'' + ", mChannelName='" + mChannelName + '\'' + ", mChannelId='" + mChannelId + '\'' + ", mUserId=" + mUserId + ", mUid=" + mUid + ", mTitle='" + mTitle + '\'' + ", mText='" + mText + '\'' + ", mIcon=" + mIcon + ", mPostedTimeMs=" + mPostedTimeMs + ", mConversationId=" + mConversationId + '}'; } @Override public boolean equals(@Nullable Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; HistoricalNotification that = (HistoricalNotification) o; boolean iconsAreSame = getIcon() == null && that.getIcon() == null || (getIcon() != null && that.getIcon() != null && getIcon().sameAs(that.getIcon())); return getUid() == that.getUid() && getUserId() == that.getUserId() && getPostedTimeMs() == that.getPostedTimeMs() && Objects.equals(getPackage(), that.getPackage()) && Objects.equals(getChannelName(), that.getChannelName()) && Objects.equals(getChannelId(), that.getChannelId()) && Objects.equals(getTitle(), that.getTitle()) && Objects.equals(getText(), that.getText()) && Objects.equals(getConversationId(), that.getConversationId()) && iconsAreSame; } @Override public int hashCode() { return Objects.hash(getPackage(), getChannelName(), getChannelId(), getUid(), getUserId(), getPostedTimeMs(), getTitle(), getText(), getIcon(), getConversationId()); } public static final class Builder { private String mPackage; private String mChannelName; private String mChannelId; private int mUid; private @UserIdInt int mUserId; private long mPostedTimeMs; private String mTitle; private String mText; private Icon mIcon; private String mConversationId; public Builder() {} public Builder setPackage(String aPackage) { mPackage = aPackage; return this; } public Builder setChannelName(String channelName) { mChannelName = channelName; return this; } public Builder setChannelId(String channelId) { mChannelId = channelId; return this; } public Builder setUid(int uid) { mUid = uid; return this; } public Builder setUserId(int userId) { mUserId = userId; return this; } public Builder setPostedTimeMs(long postedTimeMs) { mPostedTimeMs = postedTimeMs; return this; } public Builder setTitle(String title) { mTitle = title; return this; } public Builder setText(String text) { mText = text; return this; } public Builder setIcon(Icon icon) { mIcon = icon; return this; } public Builder setConversationId(String conversationId) { mConversationId = conversationId; return this; } public HistoricalNotification build() { HistoricalNotification n = new HistoricalNotification(); n.mPackage = mPackage; n.mChannelName = mChannelName; n.mChannelId = mChannelId; n.mUid = mUid; n.mUserId = mUserId; n.mPostedTimeMs = mPostedTimeMs; n.mTitle = mTitle; n.mText = mText; n.mIcon = mIcon; n.mConversationId = mConversationId; return n; } } } // Only used when creating the resulting history. Not used for reading/unparceling. private List mNotificationsToWrite = new ArrayList<>(); // ditto private Set mStringsToWrite = new HashSet<>(); // Mostly used for reading/unparceling events. private Parcel mParcel = null; private int mHistoryCount; private int mIndex = 0; // Sorted array of commonly used strings to shrink the size of the parcel. populated from // mStringsToWrite on write and the parcel on read. private String[] mStringPool; /** * Construct the iterator from a parcel. */ private NotificationHistory(Parcel in) { byte[] bytes = in.readBlob(); Parcel data = Parcel.obtain(); data.unmarshall(bytes, 0, bytes.length); data.setDataPosition(0); mHistoryCount = data.readInt(); mIndex = data.readInt(); if (mHistoryCount > 0) { mStringPool = data.createStringArray(); final int listByteLength = data.readInt(); final int positionInParcel = data.readInt(); mParcel = Parcel.obtain(); mParcel.setDataPosition(0); mParcel.appendFrom(data, data.dataPosition(), listByteLength); mParcel.setDataSize(mParcel.dataPosition()); mParcel.setDataPosition(positionInParcel); } } /** * Create an empty iterator. */ public NotificationHistory() { mHistoryCount = 0; } /** * Returns whether or not there are more events to read using {@link #getNextNotification()}. * * @return true if there are more events, false otherwise. */ public boolean hasNextNotification() { return mIndex < mHistoryCount; } /** * Retrieve the next {@link HistoricalNotification} from the collection and put the * resulting data into {@code notificationOut}. * * @return The next {@link HistoricalNotification} or null if there are no more notifications. */ public @Nullable HistoricalNotification getNextNotification() { if (!hasNextNotification()) { return null; } HistoricalNotification n = readNotificationFromParcel(mParcel); mIndex++; if (!hasNextNotification()) { mParcel.recycle(); mParcel = null; } return n; } /** * Adds all of the pooled strings that have been read from disk */ public void addPooledStrings(@NonNull List strings) { mStringsToWrite.addAll(strings); } /** * Builds the pooled strings from pending notifications. Useful if the pooled strings on * disk contains strings that aren't relevant to the notifications in our collection. */ public void poolStringsFromNotifications() { mStringsToWrite.clear(); for (int i = 0; i < mNotificationsToWrite.size(); i++) { final HistoricalNotification notification = mNotificationsToWrite.get(i); mStringsToWrite.add(notification.getPackage()); mStringsToWrite.add(notification.getChannelName()); mStringsToWrite.add(notification.getChannelId()); if (!TextUtils.isEmpty(notification.getConversationId())) { mStringsToWrite.add(notification.getConversationId()); } } } /** * Used when populating a history from disk; adds an historical notification. */ public void addNotificationToWrite(@NonNull HistoricalNotification notification) { if (notification == null) { return; } mNotificationsToWrite.add(notification); mHistoryCount++; } /** * Used when populating a history from disk; adds an historical notification. */ public void addNewNotificationToWrite(@NonNull HistoricalNotification notification) { if (notification == null) { return; } mNotificationsToWrite.add(0, notification); mHistoryCount++; } public void addNotificationsToWrite(@NonNull NotificationHistory notificationHistory) { for (HistoricalNotification hn : notificationHistory.getNotificationsToWrite()) { addNotificationToWrite(hn); } Collections.sort(mNotificationsToWrite, (o1, o2) -> -1 * Long.compare(o1.getPostedTimeMs(), o2.getPostedTimeMs())); poolStringsFromNotifications(); } /** * Removes a package's historical notifications and regenerates the string pool */ public void removeNotificationsFromWrite(String packageName) { for (int i = mNotificationsToWrite.size() - 1; i >= 0; i--) { if (packageName.equals(mNotificationsToWrite.get(i).getPackage())) { mNotificationsToWrite.remove(i); } } poolStringsFromNotifications(); } /** * Removes an individual historical notification and regenerates the string pool */ public boolean removeNotificationFromWrite(String packageName, long postedTime) { boolean removed = false; for (int i = mNotificationsToWrite.size() - 1; i >= 0; i--) { HistoricalNotification hn = mNotificationsToWrite.get(i); if (packageName.equals(hn.getPackage()) && postedTime == hn.getPostedTimeMs()) { removed = true; mNotificationsToWrite.remove(i); } } if (removed) { poolStringsFromNotifications(); } return removed; } /** * Removes all notifications from a conversation and regenerates the string pool */ public boolean removeConversationsFromWrite(String packageName, Set conversationIds) { boolean removed = false; for (int i = mNotificationsToWrite.size() - 1; i >= 0; i--) { HistoricalNotification hn = mNotificationsToWrite.get(i); if (packageName.equals(hn.getPackage()) && hn.getConversationId() != null && conversationIds.contains(hn.getConversationId())) { removed = true; mNotificationsToWrite.remove(i); } } if (removed) { poolStringsFromNotifications(); } return removed; } /** * Removes all notifications from a channel and regenerates the string pool */ public boolean removeChannelFromWrite(String packageName, String channelId) { boolean removed = false; for (int i = mNotificationsToWrite.size() - 1; i >= 0; i--) { HistoricalNotification hn = mNotificationsToWrite.get(i); if (packageName.equals(hn.getPackage()) && Objects.equals(channelId, hn.getChannelId())) { removed = true; mNotificationsToWrite.remove(i); } } if (removed) { poolStringsFromNotifications(); } return removed; } /** * Gets pooled strings in order to write them to disk */ public @NonNull String[] getPooledStringsToWrite() { String[] stringsToWrite = mStringsToWrite.toArray(new String[]{}); Arrays.sort(stringsToWrite); return stringsToWrite; } /** * Gets the historical notifications in order to write them to disk */ public @NonNull List getNotificationsToWrite() { return mNotificationsToWrite; } /** * Gets the number of notifications in the collection */ public int getHistoryCount() { return mHistoryCount; } private int findStringIndex(String str) { final int index = Arrays.binarySearch(mStringPool, str); if (index < 0) { throw new IllegalStateException("String '" + str + "' is not in the string pool"); } return index; } /** * Writes a single notification to the parcel. Modify this when updating member variables of * {@link HistoricalNotification}. */ private void writeNotificationToParcel(HistoricalNotification notification, Parcel p, int flags) { final int packageIndex; if (notification.mPackage != null) { packageIndex = findStringIndex(notification.mPackage); } else { packageIndex = -1; } final int channelNameIndex; if (notification.getChannelName() != null) { channelNameIndex = findStringIndex(notification.getChannelName()); } else { channelNameIndex = -1; } final int channelIdIndex; if (notification.getChannelId() != null) { channelIdIndex = findStringIndex(notification.getChannelId()); } else { channelIdIndex = -1; } final int conversationIdIndex; if (!TextUtils.isEmpty(notification.getConversationId())) { conversationIdIndex = findStringIndex(notification.getConversationId()); } else { conversationIdIndex = -1; } p.writeInt(packageIndex); p.writeInt(channelNameIndex); p.writeInt(channelIdIndex); p.writeInt(conversationIdIndex); p.writeInt(notification.getUid()); p.writeInt(notification.getUserId()); p.writeLong(notification.getPostedTimeMs()); p.writeString(notification.getTitle()); p.writeString(notification.getText()); p.writeBoolean(false); // The current design does not display icons, so don't bother adding them to the parcel //if (notification.getIcon() != null) { // notification.getIcon().writeToParcel(p, flags); //} } /** * Reads a single notification from the parcel. Modify this when updating member variables of * {@link HistoricalNotification}. */ private HistoricalNotification readNotificationFromParcel(Parcel p) { HistoricalNotification.Builder notificationOut = new HistoricalNotification.Builder(); final int packageIndex = p.readInt(); if (packageIndex >= 0) { notificationOut.mPackage = mStringPool[packageIndex]; } else { notificationOut.mPackage = null; } final int channelNameIndex = p.readInt(); if (channelNameIndex >= 0) { notificationOut.setChannelName(mStringPool[channelNameIndex]); } else { notificationOut.setChannelName(null); } final int channelIdIndex = p.readInt(); if (channelIdIndex >= 0) { notificationOut.setChannelId(mStringPool[channelIdIndex]); } else { notificationOut.setChannelId(null); } final int conversationIdIndex = p.readInt(); if (conversationIdIndex >= 0) { notificationOut.setConversationId(mStringPool[conversationIdIndex]); } else { notificationOut.setConversationId(null); } notificationOut.setUid(p.readInt()); notificationOut.setUserId(p.readInt()); notificationOut.setPostedTimeMs(p.readLong()); notificationOut.setTitle(p.readString()); notificationOut.setText(p.readString()); if (p.readBoolean()) { notificationOut.setIcon(Icon.CREATOR.createFromParcel(p)); } return notificationOut.build(); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { Parcel data = Parcel.obtain(); data.writeInt(mHistoryCount); data.writeInt(mIndex); if (mHistoryCount > 0) { mStringPool = getPooledStringsToWrite(); data.writeStringArray(mStringPool); if (!mNotificationsToWrite.isEmpty()) { // typically system_server to a process // Write out the events Parcel p = Parcel.obtain(); try { p.setDataPosition(0); for (int i = 0; i < mHistoryCount; i++) { final HistoricalNotification notification = mNotificationsToWrite.get(i); writeNotificationToParcel(notification, p, flags); } final int listByteLength = p.dataPosition(); // Write the total length of the data. data.writeInt(listByteLength); // Write our current position into the data. data.writeInt(0); // Write the data. data.appendFrom(p, 0, listByteLength); } finally { p.recycle(); } } else if (mParcel != null) { // typically process to process as mNotificationsToWrite is not populated on // unparcel. // Write the total length of the data. data.writeInt(mParcel.dataSize()); // Write out current position into the data. data.writeInt(mParcel.dataPosition()); // Write the data. data.appendFrom(mParcel, 0, mParcel.dataSize()); } else { throw new IllegalStateException( "Either mParcel or mNotificationsToWrite must not be null"); } } // Data can be too large for a transact. Write the data as a Blob, which will be written to // ashmem if too large. dest.writeBlob(data.marshall()); data.recycle(); } public static final @NonNull Creator CREATOR = new Creator() { @Override public NotificationHistory createFromParcel(Parcel source) { return new NotificationHistory(source); } @Override public NotificationHistory[] newArray(int size) { return new NotificationHistory[size]; } }; }