OSDN Git Service

Add ability to write and read Notification history.
authorJulia Reynolds <juliacr@google.com>
Mon, 21 Oct 2019 15:37:35 +0000 (11:37 -0400)
committerJulia Reynolds <juliacr@google.com>
Tue, 29 Oct 2019 13:50:06 +0000 (09:50 -0400)
Bug: 137396965
Test: atest
Change-Id: I6bfdd0f6906dcf9c58b592e03bef335c4920d94d

core/java/android/app/NotificationHistory.aidl [new file with mode: 0644]
core/java/android/app/NotificationHistory.java [new file with mode: 0644]
core/tests/coretests/src/android/app/NotificationHistoryTest.java [new file with mode: 0644]
services/core/java/com/android/server/notification/NotificationHistoryDatabase.java [new file with mode: 0644]
services/core/java/com/android/server/notification/NotificationHistoryFilter.java [new file with mode: 0644]
services/core/java/com/android/server/notification/NotificationHistoryProtoHelper.java [new file with mode: 0644]
services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryDatabaseTest.java [new file with mode: 0644]
services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryFilterTest.java [new file with mode: 0644]
services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryProtoHelperTest.java [new file with mode: 0644]

diff --git a/core/java/android/app/NotificationHistory.aidl b/core/java/android/app/NotificationHistory.aidl
new file mode 100644 (file)
index 0000000..8150e74
--- /dev/null
@@ -0,0 +1,19 @@
+/**
+ * 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;
+
+parcelable NotificationHistory;
\ No newline at end of file
diff --git a/core/java/android/app/NotificationHistory.java b/core/java/android/app/NotificationHistory.java
new file mode 100644 (file)
index 0000000..c35246b
--- /dev/null
@@ -0,0 +1,506 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Arrays;
+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 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;
+        }
+
+        @Override
+        public String toString() {
+            return "HistoricalNotification{" +
+                    "key='" + getKey() + '\'' +
+                    ", mChannelName='" + mChannelName + '\'' +
+                    ", mChannelId='" + mChannelId + '\'' +
+                    ", mUserId=" + mUserId +
+                    ", mTitle='" + mTitle + '\'' +
+                    ", mText='" + mText + '\'' +
+                    ", mIcon=" + mIcon +
+                    '}';
+        }
+
+        @Override
+        public boolean equals(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()) &&
+                    iconsAreSame;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(getPackage(), getChannelName(), getChannelId(), getUid(),
+                    getUserId(),
+                    getPostedTimeMs(), getTitle(), getText(), getIcon());
+        }
+
+        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;
+
+            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 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;
+                return n;
+            }
+        }
+    }
+
+    // Only used when creating the resulting history. Not used for reading/unparceling.
+    private List<HistoricalNotification> mNotificationsToWrite = new ArrayList<>();
+    // ditto
+    private Set<String> 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<String> 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());
+        }
+    }
+
+    /**
+     * 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++;
+    }
+
+    /**
+     * 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();
+    }
+
+    /**
+     * 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<HistoricalNotification> 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;
+        }
+
+        p.writeInt(packageIndex);
+        p.writeInt(channelNameIndex);
+        p.writeInt(channelIdIndex);
+        p.writeInt(notification.getUid());
+        p.writeInt(notification.getUserId());
+        p.writeLong(notification.getPostedTimeMs());
+        p.writeString(notification.getTitle());
+        p.writeString(notification.getText());
+        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);
+        }
+
+        notificationOut.setUid(p.readInt());
+        notificationOut.setUserId(p.readInt());
+        notificationOut.setPostedTimeMs(p.readLong());
+        notificationOut.setTitle(p.readString());
+        notificationOut.setText(p.readString());
+        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());
+    }
+
+    public static final @NonNull Creator<NotificationHistory> CREATOR
+            = new Creator<NotificationHistory>() {
+        @Override
+        public NotificationHistory createFromParcel(Parcel source) {
+            return new NotificationHistory(source);
+        }
+
+        @Override
+        public NotificationHistory[] newArray(int size) {
+            return new NotificationHistory[size];
+        }
+    };
+}
diff --git a/core/tests/coretests/src/android/app/NotificationHistoryTest.java b/core/tests/coretests/src/android/app/NotificationHistoryTest.java
new file mode 100644 (file)
index 0000000..08595bb
--- /dev/null
@@ -0,0 +1,232 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.app.NotificationHistory.HistoricalNotification;
+import android.graphics.drawable.Icon;
+import android.os.Parcel;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class NotificationHistoryTest {
+
+    private HistoricalNotification getHistoricalNotification(int index) {
+        return getHistoricalNotification("package" + index, index);
+    }
+
+    private HistoricalNotification getHistoricalNotification(String packageName, int index) {
+        String expectedChannelName = "channelName" + index;
+        String expectedChannelId = "channelId" + index;
+        int expectedUid = 1123456 + index;
+        int expectedUserId = 11 + index;
+        long expectedPostTime = 987654321 + index;
+        String expectedTitle = "title" + index;
+        String expectedText = "text" + index;
+        Icon expectedIcon = Icon.createWithResource(InstrumentationRegistry.getContext(),
+                index);
+
+        return new HistoricalNotification.Builder()
+                .setPackage(packageName)
+                .setChannelName(expectedChannelName)
+                .setChannelId(expectedChannelId)
+                .setUid(expectedUid)
+                .setUserId(expectedUserId)
+                .setPostedTimeMs(expectedPostTime)
+                .setTitle(expectedTitle)
+                .setText(expectedText)
+                .setIcon(expectedIcon)
+                .build();
+    }
+
+    @Test
+    public void testHistoricalNotificationBuilder() {
+        String expectedPackage = "package";
+        String expectedChannelName = "channelName";
+        String expectedChannelId = "channelId";
+        int expectedUid = 1123456;
+        int expectedUserId = 11;
+        long expectedPostTime = 987654321;
+        String expectedTitle = "title";
+        String expectedText = "text";
+        Icon expectedIcon = Icon.createWithResource(InstrumentationRegistry.getContext(),
+                android.R.drawable.btn_star);
+
+        HistoricalNotification n = new HistoricalNotification.Builder()
+                .setPackage(expectedPackage)
+                .setChannelName(expectedChannelName)
+                .setChannelId(expectedChannelId)
+                .setUid(expectedUid)
+                .setUserId(expectedUserId)
+                .setPostedTimeMs(expectedPostTime)
+                .setTitle(expectedTitle)
+                .setText(expectedText)
+                .setIcon(expectedIcon)
+                .build();
+
+        assertThat(n.getPackage()).isEqualTo(expectedPackage);
+        assertThat(n.getChannelName()).isEqualTo(expectedChannelName);
+        assertThat(n.getChannelId()).isEqualTo(expectedChannelId);
+        assertThat(n.getUid()).isEqualTo(expectedUid);
+        assertThat(n.getUserId()).isEqualTo(expectedUserId);
+        assertThat(n.getPostedTimeMs()).isEqualTo(expectedPostTime);
+        assertThat(n.getTitle()).isEqualTo(expectedTitle);
+        assertThat(n.getText()).isEqualTo(expectedText);
+        assertThat(expectedIcon.sameAs(n.getIcon())).isTrue();
+    }
+
+    @Test
+    public void testAddNotificationToWrite() {
+        NotificationHistory history = new NotificationHistory();
+        HistoricalNotification n = getHistoricalNotification(0);
+        HistoricalNotification n2 = getHistoricalNotification(1);
+
+        history.addNotificationToWrite(n2);
+        history.addNotificationToWrite(n);
+
+        assertThat(history.getNotificationsToWrite().size()).isEqualTo(2);
+        assertThat(history.getNotificationsToWrite().get(0)).isSameAs(n2);
+        assertThat(history.getNotificationsToWrite().get(1)).isSameAs(n);
+        assertThat(history.getHistoryCount()).isEqualTo(2);
+    }
+
+    @Test
+    public void testPoolStringsFromNotifications() {
+        NotificationHistory history = new NotificationHistory();
+
+        List<String> expectedStrings = new ArrayList<>();
+        for (int i = 1; i <= 10; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedStrings.add(n.getPackage());
+            expectedStrings.add(n.getChannelName());
+            expectedStrings.add(n.getChannelId());
+            history.addNotificationToWrite(n);
+        }
+
+        history.poolStringsFromNotifications();
+
+        assertThat(history.getPooledStringsToWrite().length).isEqualTo(expectedStrings.size());
+        String previous = null;
+        for (String actual : history.getPooledStringsToWrite()) {
+            assertThat(expectedStrings).contains(actual);
+
+            if (previous != null) {
+                assertThat(actual).isGreaterThan(previous);
+            }
+            previous = actual;
+        }
+    }
+
+    @Test
+    public void testAddPooledStrings() {
+        NotificationHistory history = new NotificationHistory();
+
+        List<String> expectedStrings = new ArrayList<>();
+        for (int i = 1; i <= 10; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedStrings.add(n.getPackage());
+            expectedStrings.add(n.getChannelName());
+            expectedStrings.add(n.getChannelId());
+            history.addNotificationToWrite(n);
+        }
+
+        history.addPooledStrings(expectedStrings);
+
+        String[] actualStrings = history.getPooledStringsToWrite();
+        assertThat(actualStrings.length).isEqualTo(expectedStrings.size());
+        String previous = null;
+        for (String actual : actualStrings) {
+            assertThat(expectedStrings).contains(actual);
+
+            if (previous != null) {
+                assertThat(actual).isGreaterThan(previous);
+            }
+            previous = actual;
+        }
+    }
+
+    @Test
+    public void testRemoveNotificationsFromWrite() {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> postRemoveExpectedEntries = new ArrayList<>();
+        List<String> postRemoveExpectedStrings = new ArrayList<>();
+        for (int i = 1; i <= 10; i++) {
+            HistoricalNotification n =
+                    getHistoricalNotification((i % 2 == 0) ? "pkgEven" : "pkgOdd", i);
+
+            if (i % 2 == 0) {
+                postRemoveExpectedStrings.add(n.getPackage());
+                postRemoveExpectedStrings.add(n.getChannelName());
+                postRemoveExpectedStrings.add(n.getChannelId());
+                postRemoveExpectedEntries.add(n);
+            }
+
+            history.addNotificationToWrite(n);
+        }
+
+        history.poolStringsFromNotifications();
+
+        assertThat(history.getNotificationsToWrite().size()).isEqualTo(10);
+        // 2 package names and 10 * 2 unique channel names and ids
+        assertThat(history.getPooledStringsToWrite().length).isEqualTo(22);
+
+        history.removeNotificationsFromWrite("pkgOdd");
+
+
+        // 1 package names and 5 * 2 unique channel names and ids
+        assertThat(history.getPooledStringsToWrite().length).isEqualTo(11);
+        assertThat(history.getNotificationsToWrite())
+                .containsExactlyElementsIn(postRemoveExpectedEntries);
+    }
+
+    @Test
+    public void testParceling() {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        for (int i = 10; i >= 1; i--) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedEntries.add(n);
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        Parcel parcel = Parcel.obtain();
+        history.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        NotificationHistory parceledHistory = NotificationHistory.CREATOR.createFromParcel(parcel);
+
+        assertThat(parceledHistory.getHistoryCount()).isEqualTo(expectedEntries.size());
+
+        for (int i = 0; i < expectedEntries.size(); i++) {
+            assertThat(parceledHistory.hasNextNotification()).isTrue();
+
+            HistoricalNotification postParcelNotification = parceledHistory.getNextNotification();
+            assertThat(postParcelNotification).isEqualTo(expectedEntries.get(i));
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/notification/NotificationHistoryDatabase.java b/services/core/java/com/android/server/notification/NotificationHistoryDatabase.java
new file mode 100644 (file)
index 0000000..99b1ef4
--- /dev/null
@@ -0,0 +1,300 @@
+/*
+ * 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 com.android.server.notification;
+
+import android.app.NotificationHistory;
+import android.app.NotificationHistory.HistoricalNotification;
+import android.os.Handler;
+import android.util.AtomicFile;
+import android.util.Slog;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.Iterator;
+import java.util.LinkedList;
+
+/**
+ * Provides an interface to write and query for notification history data for a user from a Protocol
+ * Buffer database.
+ *
+ * Periodically writes the buffered history to disk but can also accept force writes based on
+ * outside changes (like a pending shutdown).
+ */
+public class NotificationHistoryDatabase {
+    private static final int DEFAULT_CURRENT_VERSION = 1;
+
+    private static final String TAG = "NotiHistoryDatabase";
+    private static final boolean DEBUG = NotificationManagerService.DBG;
+    private static final int HISTORY_RETENTION_DAYS = 2;
+    private static final long WRITE_BUFFER_INTERVAL_MS = 1000 * 60 * 20;
+
+    private final Object mLock = new Object();
+    private Handler mFileWriteHandler;
+    @VisibleForTesting
+    // List of files holding history information, sorted newest to oldest
+    final LinkedList<AtomicFile> mHistoryFiles;
+    private final GregorianCalendar mCal;
+    private final File mHistoryDir;
+    private final File mVersionFile;
+    // Current version of the database files schema
+    private int mCurrentVersion;
+    private final WriteBufferRunnable mWriteBufferRunnable;
+
+    // Object containing posted notifications that have not yet been written to disk
+    @VisibleForTesting
+    NotificationHistory mBuffer;
+
+    public NotificationHistoryDatabase(File dir) {
+        mCurrentVersion = DEFAULT_CURRENT_VERSION;
+        mVersionFile = new File(dir, "version");
+        mHistoryDir = new File(dir, "history");
+        mHistoryFiles = new LinkedList<>();
+        mCal = new GregorianCalendar();
+        mBuffer = new NotificationHistory();
+        mWriteBufferRunnable = new WriteBufferRunnable();
+    }
+
+    public void init(Handler fileWriteHandler) {
+        synchronized (mLock) {
+            mFileWriteHandler = fileWriteHandler;
+
+            try {
+                mHistoryDir.mkdir();
+                mVersionFile.createNewFile();
+            } catch (Exception e) {
+                Slog.e(TAG, "could not create needed files", e);
+            }
+
+            checkVersionAndBuildLocked();
+            indexFilesLocked();
+            prune(HISTORY_RETENTION_DAYS, System.currentTimeMillis());
+        }
+    }
+
+    private void indexFilesLocked() {
+        mHistoryFiles.clear();
+        final File[] files = mHistoryDir.listFiles();
+        if (files == null) {
+            return;
+        }
+
+        // Sort with newest files first
+        Arrays.sort(files, (lhs, rhs) -> Long.compare(rhs.lastModified(), lhs.lastModified()));
+
+        for (File file : files) {
+            mHistoryFiles.addLast(new AtomicFile(file));
+        }
+    }
+
+    private void checkVersionAndBuildLocked() {
+        int version;
+        try (BufferedReader reader = new BufferedReader(new FileReader(mVersionFile))) {
+            version = Integer.parseInt(reader.readLine());
+        } catch (NumberFormatException | IOException e) {
+            version = 0;
+        }
+
+        if (version != mCurrentVersion && mVersionFile.exists()) {
+            try (BufferedWriter writer = new BufferedWriter(new FileWriter(mVersionFile))) {
+                writer.write(Integer.toString(mCurrentVersion));
+                writer.write("\n");
+                writer.flush();
+            } catch (IOException e) {
+                Slog.e(TAG, "Failed to write new version");
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    void forceWriteToDisk() {
+        if (!mFileWriteHandler.hasCallbacks(mWriteBufferRunnable)) {
+            mFileWriteHandler.post(mWriteBufferRunnable);
+        }
+    }
+
+    void onPackageRemoved(String packageName) {
+        RemovePackageRunnable rpr = new RemovePackageRunnable(packageName);
+        mFileWriteHandler.post(rpr);
+    }
+
+    public void addNotification(final HistoricalNotification notification) {
+        synchronized (mLock) {
+            mBuffer.addNotificationToWrite(notification);
+            // Each time we have new history to write to disk, schedule a write in [interval] ms
+            if (mBuffer.getHistoryCount() == 1) {
+                mFileWriteHandler.postDelayed(mWriteBufferRunnable, WRITE_BUFFER_INTERVAL_MS);
+            }
+        }
+    }
+
+    public NotificationHistory readNotificationHistory() {
+        synchronized (mLock) {
+            NotificationHistory notifications = new NotificationHistory();
+
+            for (AtomicFile file : mHistoryFiles) {
+                try {
+                    readLocked(
+                            file, notifications, new NotificationHistoryFilter.Builder().build());
+                } catch (Exception e) {
+                    Slog.e(TAG, "error reading " + file.getBaseFile().getName(), e);
+                }
+            }
+
+            return notifications;
+        }
+    }
+
+    public NotificationHistory readNotificationHistory(String packageName, String channelId,
+            int maxNotifications) {
+        synchronized (mLock) {
+            NotificationHistory notifications = new NotificationHistory();
+
+            for (AtomicFile file : mHistoryFiles) {
+                try {
+                    readLocked(file, notifications,
+                            new NotificationHistoryFilter.Builder()
+                                    .setPackage(packageName)
+                                    .setChannel(packageName, channelId)
+                                    .setMaxNotifications(maxNotifications)
+                                    .build());
+                    if (maxNotifications == notifications.getHistoryCount()) {
+                        // No need to read any more files
+                        break;
+                    }
+                } catch (Exception e) {
+                    Slog.e(TAG, "error reading " + file.getBaseFile().getName(), e);
+                }
+            }
+
+            return notifications;
+        }
+    }
+
+    /**
+     * Remove any files that are too old.
+     */
+    public void prune(final int retentionDays, final long currentTimeMillis) {
+        synchronized (mLock) {
+            mCal.setTimeInMillis(currentTimeMillis);
+            mCal.add(Calendar.DATE, -1 * retentionDays);
+
+            while (!mHistoryFiles.isEmpty()) {
+                final AtomicFile currentOldestFile = mHistoryFiles.getLast();
+                final long age = currentTimeMillis
+                        - currentOldestFile.getBaseFile().lastModified();
+                if (age > mCal.getTimeInMillis()) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Removed " + currentOldestFile.getBaseFile().getName());
+                    }
+                    currentOldestFile.delete();
+                    mHistoryFiles.removeLast();
+                } else {
+                    // all remaining files are newer than the cut off
+                    return;
+                }
+            }
+        }
+    }
+
+    private void writeLocked(AtomicFile file, NotificationHistory notifications)
+            throws IOException {
+        FileOutputStream fos = file.startWrite();
+        try {
+            NotificationHistoryProtoHelper.write(fos, notifications, mCurrentVersion);
+            file.finishWrite(fos);
+            fos = null;
+        } finally {
+            // When fos is null (successful write), this will no-op
+            file.failWrite(fos);
+        }
+    }
+
+    private static void readLocked(AtomicFile file, NotificationHistory notificationsOut,
+            NotificationHistoryFilter filter) throws IOException {
+        try (FileInputStream in = file.openRead()) {
+            NotificationHistoryProtoHelper.read(in, notificationsOut, filter);
+        } catch (FileNotFoundException e) {
+            Slog.e(TAG, "Cannot file " + file.getBaseFile().getName(), e);
+            throw e;
+        }
+    }
+
+    private final class WriteBufferRunnable implements Runnable {
+        @Override
+        public void run() {
+            if (DEBUG) Slog.d(TAG, "WriteBufferRunnable");
+            synchronized (mLock) {
+                final AtomicFile latestNotificationsFiles = new AtomicFile(
+                        new File(mHistoryDir, String.valueOf(System.currentTimeMillis())));
+                try {
+                    writeLocked(latestNotificationsFiles, mBuffer);
+                    mHistoryFiles.addFirst(latestNotificationsFiles);
+                    mBuffer = new NotificationHistory();
+                } catch (IOException e) {
+                    Slog.e(TAG, "Failed to write buffer to disk. not flushing buffer", e);
+                }
+            }
+        }
+    }
+
+    private final class RemovePackageRunnable implements Runnable {
+        private String mPkg;
+
+        public RemovePackageRunnable(String pkg) {
+            mPkg = pkg;
+        }
+
+        @Override
+        public void run() {
+            if (DEBUG) Slog.d(TAG, "RemovePackageRunnable");
+            synchronized (mLock) {
+                // Remove packageName entries from pending history
+                mBuffer.removeNotificationsFromWrite(mPkg);
+
+                // Remove packageName entries from files on disk, and rewrite them to disk
+                // Since we sort by modified date, we have to update the files oldest to newest to
+                // maintain the original ordering
+                Iterator<AtomicFile> historyFileItr = mHistoryFiles.descendingIterator();
+                while (historyFileItr.hasNext()) {
+                    final AtomicFile af = historyFileItr.next();
+                    try {
+                        final NotificationHistory notifications = new NotificationHistory();
+                        readLocked(af, notifications,
+                                new NotificationHistoryFilter.Builder().build());
+                        notifications.removeNotificationsFromWrite(mPkg);
+                        writeLocked(af, notifications);
+                    } catch (Exception e) {
+                        Slog.e(TAG, "Cannot clean up file on pkg removal "
+                                + af.getBaseFile().getName(), e);
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/notification/NotificationHistoryFilter.java b/services/core/java/com/android/server/notification/NotificationHistoryFilter.java
new file mode 100644 (file)
index 0000000..c3b2e73
--- /dev/null
@@ -0,0 +1,125 @@
+/*
+ * 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 com.android.server.notification;
+
+import android.annotation.NonNull;
+import android.app.NotificationHistory;
+import android.app.NotificationHistory.HistoricalNotification;
+import android.text.TextUtils;
+
+import com.android.internal.util.Preconditions;
+
+public final class NotificationHistoryFilter {
+    private String mPackage;
+    private String mChannel;
+    private int mNotificationCount;
+
+    private NotificationHistoryFilter() {}
+
+    public String getPackage() {
+        return mPackage;
+    }
+
+    public String getChannel() {
+        return mChannel;
+    }
+
+    public int getMaxNotifications() {
+        return mNotificationCount;
+    }
+
+    /**
+     * Returns whether any of the filtering conditions are set
+     */
+    public boolean isFiltering() {
+        return getPackage() != null || getChannel() != null
+                || mNotificationCount < Integer.MAX_VALUE;
+    }
+
+    /**
+     * Returns true if this notification passes the package and channel name filter, false
+     * otherwise.
+     */
+    public boolean matchesPackageAndChannelFilter(HistoricalNotification notification) {
+        if (!TextUtils.isEmpty(getPackage())) {
+            if (!getPackage().equals(notification.getPackage())) {
+                return false;
+            } else {
+                if (!TextUtils.isEmpty(getChannel())
+                        && !getChannel().equals(notification.getChannelId())) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns true if the NotificationHistory can accept another notification.
+     */
+    public boolean matchesCountFilter(NotificationHistory notifications) {
+        return notifications.getHistoryCount() < mNotificationCount;
+    }
+
+    public static final class Builder {
+        private String mPackage = null;
+        private String mChannel = null;
+        private int mNotificationCount = Integer.MAX_VALUE;
+
+        /**
+         * Constructor
+         */
+        public Builder() {}
+
+        /**
+         * Sets a package name filter
+         */
+        public Builder setPackage(String aPackage) {
+            mPackage = aPackage;
+            return this;
+        }
+
+        /**
+         * Sets a channel name filter. Only valid if there is also a package name filter
+         */
+        public Builder setChannel(String pkg, String channel) {
+            setPackage(pkg);
+            mChannel = channel;
+            return this;
+        }
+
+        /**
+         * Sets the max historical notifications
+         */
+        public Builder setMaxNotifications(int notificationCount) {
+            mNotificationCount = notificationCount;
+            return this;
+        }
+
+        /**
+         * Makes a NotificationHistoryFilter
+         */
+        public NotificationHistoryFilter build() {
+            NotificationHistoryFilter filter = new NotificationHistoryFilter();
+            filter.mPackage = mPackage;
+            filter.mChannel = mChannel;
+            filter.mNotificationCount = mNotificationCount;
+            return filter;
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/notification/NotificationHistoryProtoHelper.java b/services/core/java/com/android/server/notification/NotificationHistoryProtoHelper.java
new file mode 100644 (file)
index 0000000..2831d37
--- /dev/null
@@ -0,0 +1,321 @@
+/*
+ * 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 com.android.server.notification;
+
+import android.app.NotificationHistory;
+import android.app.NotificationHistory.HistoricalNotification;
+import android.content.res.Resources;
+import android.graphics.drawable.Icon;
+import android.util.Slog;
+import android.util.proto.ProtoInputStream;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.server.notification.NotificationHistoryProto.Notification;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Notification history reader/writer for Protocol Buffer format
+ */
+final class NotificationHistoryProtoHelper {
+    private static final String TAG = "NotifHistoryProto";
+
+    // Static-only utility class.
+    private NotificationHistoryProtoHelper() {}
+
+    private static List<String> readStringPool(ProtoInputStream proto) throws IOException {
+        final long token = proto.start(NotificationHistoryProto.STRING_POOL);
+        List<String> stringPool;
+        if (proto.nextField(NotificationHistoryProto.StringPool.SIZE)) {
+            stringPool = new ArrayList(proto.readInt(NotificationHistoryProto.StringPool.SIZE));
+        } else {
+            stringPool = new ArrayList();
+        }
+        while (proto.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
+            switch (proto.getFieldNumber()) {
+                case (int) NotificationHistoryProto.StringPool.STRINGS:
+                    stringPool.add(proto.readString(NotificationHistoryProto.StringPool.STRINGS));
+                    break;
+            }
+        }
+        proto.end(token);
+        return stringPool;
+    }
+
+    private static void writeStringPool(ProtoOutputStream proto,
+            final NotificationHistory notifications) {
+        final long token = proto.start(NotificationHistoryProto.STRING_POOL);
+        final String[] pooledStrings = notifications.getPooledStringsToWrite();
+        proto.write(NotificationHistoryProto.StringPool.SIZE, pooledStrings.length);
+        for (int i = 0; i < pooledStrings.length; i++) {
+            proto.write(NotificationHistoryProto.StringPool.STRINGS, pooledStrings[i]);
+        }
+        proto.end(token);
+    }
+
+    private static void readNotification(ProtoInputStream proto, List<String> stringPool,
+            NotificationHistory notifications, NotificationHistoryFilter filter)
+            throws IOException {
+        final long token = proto.start(NotificationHistoryProto.NOTIFICATION);
+        try {
+            HistoricalNotification notification = readNotification(proto, stringPool);
+            if (filter.matchesPackageAndChannelFilter(notification)
+                    && filter.matchesCountFilter(notifications)) {
+                notifications.addNotificationToWrite(notification);
+            }
+        } catch (Exception e) {
+            Slog.e(TAG, "Error reading notification", e);
+        } finally {
+            proto.end(token);
+        }
+    }
+
+    private static HistoricalNotification readNotification(ProtoInputStream parser,
+            List<String> stringPool) throws IOException {
+        final HistoricalNotification.Builder notification = new HistoricalNotification.Builder();
+        String pkg = null;
+        while (true) {
+            switch (parser.nextField()) {
+                case (int) NotificationHistoryProto.Notification.PACKAGE:
+                    pkg = parser.readString(Notification.PACKAGE);
+                    notification.setPackage(pkg);
+                    stringPool.add(pkg);
+                    break;
+                case (int) Notification.PACKAGE_INDEX:
+                    pkg = stringPool.get(parser.readInt(Notification.PACKAGE_INDEX) - 1);
+                    notification.setPackage(pkg);
+                    break;
+                case (int) Notification.CHANNEL_NAME:
+                    String channelName = parser.readString(Notification.CHANNEL_NAME);
+                    notification.setChannelName(channelName);
+                    stringPool.add(channelName);
+                    break;
+                case (int) Notification.CHANNEL_NAME_INDEX:
+                    notification.setChannelName(stringPool.get(parser.readInt(
+                            Notification.CHANNEL_NAME_INDEX) - 1));
+                    break;
+                case (int) Notification.CHANNEL_ID:
+                    String channelId = parser.readString(Notification.CHANNEL_ID);
+                    notification.setChannelId(channelId);
+                    stringPool.add(channelId);
+                    break;
+                case (int) Notification.CHANNEL_ID_INDEX:
+                    notification.setChannelId(stringPool.get(parser.readInt(
+                            Notification.CHANNEL_ID_INDEX) - 1));
+                    break;
+                case (int) Notification.UID:
+                    notification.setUid(parser.readInt(Notification.UID));
+                    break;
+                case (int) Notification.USER_ID:
+                    notification.setUserId(parser.readInt(Notification.USER_ID));
+                    break;
+                case (int) Notification.POSTED_TIME_MS:
+                    notification.setPostedTimeMs(parser.readLong(Notification.POSTED_TIME_MS));
+                    break;
+                case (int) Notification.TITLE:
+                    notification.setTitle(parser.readString(Notification.TITLE));
+                    break;
+                case (int) Notification.TEXT:
+                    notification.setText(parser.readString(Notification.TEXT));
+                    break;
+                case (int) Notification.ICON:
+                    final long iconToken = parser.start(Notification.ICON);
+                    loadIcon(parser, notification, pkg);
+                    parser.end(iconToken);
+                    break;
+                case ProtoInputStream.NO_MORE_FIELDS:
+                    return notification.build();
+            }
+        }
+    }
+
+    private static void loadIcon(ProtoInputStream parser,
+            HistoricalNotification.Builder notification, String pkg) throws IOException {
+        int iconType = Notification.TYPE_UNKNOWN;
+        String imageBitmapFileName = null;
+        int imageResourceId = Resources.ID_NULL;
+        String imageResourceIdPackage = null;
+        byte[] imageByteData = null;
+        int imageByteDataLength = 0;
+        int imageByteDataOffset = 0;
+        String imageUri = null;
+
+        while (true) {
+            switch (parser.nextField()) {
+                case (int) Notification.Icon.IMAGE_TYPE:
+                    iconType = parser.readInt(Notification.Icon.IMAGE_TYPE);
+                    break;
+                case (int) Notification.Icon.IMAGE_DATA:
+                    imageByteData = parser.readBytes(Notification.Icon.IMAGE_DATA);
+                    break;
+                case (int) Notification.Icon.IMAGE_DATA_LENGTH:
+                    imageByteDataLength = parser.readInt(Notification.Icon.IMAGE_DATA_LENGTH);
+                    break;
+                case (int) Notification.Icon.IMAGE_DATA_OFFSET:
+                    imageByteDataOffset = parser.readInt(Notification.Icon.IMAGE_DATA_OFFSET);
+                    break;
+                case (int) Notification.Icon.IMAGE_BITMAP_FILENAME:
+                    imageBitmapFileName = parser.readString(
+                            Notification.Icon.IMAGE_BITMAP_FILENAME);
+                    break;
+                case (int) Notification.Icon.IMAGE_RESOURCE_ID:
+                    imageResourceId = parser.readInt(Notification.Icon.IMAGE_RESOURCE_ID);
+                    break;
+                case (int) Notification.Icon.IMAGE_RESOURCE_ID_PACKAGE:
+                    imageResourceIdPackage = parser.readString(
+                            Notification.Icon.IMAGE_RESOURCE_ID_PACKAGE);
+                    break;
+                case (int) Notification.Icon.IMAGE_URI:
+                    imageUri = parser.readString(Notification.Icon.IMAGE_URI);
+                    break;
+                case ProtoInputStream.NO_MORE_FIELDS:
+                    if (iconType == Icon.TYPE_DATA) {
+
+                        if (imageByteData != null) {
+                            notification.setIcon(Icon.createWithData(
+                                    imageByteData, imageByteDataOffset, imageByteDataLength));
+                        }
+                    } else if (iconType == Icon.TYPE_RESOURCE) {
+                        if (imageResourceId != Resources.ID_NULL) {
+                            notification.setIcon(Icon.createWithResource(
+                                    imageResourceIdPackage != null
+                                            ? imageResourceIdPackage
+                                            : pkg,
+                                    imageResourceId));
+                        }
+                    } else if (iconType == Icon.TYPE_URI) {
+                        if (imageUri != null) {
+                            notification.setIcon(Icon.createWithContentUri(imageUri));
+                        }
+                    } else if (iconType == Icon.TYPE_BITMAP) {
+                        // TODO: read file from disk
+                    }
+                    return;
+            }
+        }
+    }
+
+    private static void writeIcon(ProtoOutputStream proto, HistoricalNotification notification) {
+        final long token = proto.start(Notification.ICON);
+
+        proto.write(Notification.Icon.IMAGE_TYPE, notification.getIcon().getType());
+        switch (notification.getIcon().getType()) {
+            case Icon.TYPE_DATA:
+                proto.write(Notification.Icon.IMAGE_DATA, notification.getIcon().getDataBytes());
+                proto.write(Notification.Icon.IMAGE_DATA_LENGTH,
+                        notification.getIcon().getDataLength());
+                proto.write(Notification.Icon.IMAGE_DATA_OFFSET,
+                        notification.getIcon().getDataOffset());
+                break;
+            case Icon.TYPE_RESOURCE:
+                proto.write(Notification.Icon.IMAGE_RESOURCE_ID, notification.getIcon().getResId());
+                if (!notification.getPackage().equals(notification.getIcon().getResPackage())) {
+                    proto.write(Notification.Icon.IMAGE_RESOURCE_ID_PACKAGE,
+                            notification.getIcon().getResPackage());
+                }
+                break;
+            case Icon.TYPE_URI:
+                proto.write(Notification.Icon.IMAGE_URI, notification.getIcon().getUriString());
+                break;
+            case Icon.TYPE_BITMAP:
+                // TODO: write file to disk
+                break;
+        }
+
+        proto.end(token);
+    }
+
+    private static void writeNotification(ProtoOutputStream proto,
+            final String[] stringPool, final HistoricalNotification notification) {
+        final long token = proto.start(NotificationHistoryProto.NOTIFICATION);
+        final int packageIndex = Arrays.binarySearch(stringPool, notification.getPackage());
+        if (packageIndex >= 0) {
+            proto.write(Notification.PACKAGE_INDEX, packageIndex + 1);
+        } else {
+            // Package not in Stringpool for some reason, write full string instead
+            Slog.w(TAG, "notification package name (" + notification.getPackage()
+                    + ") not found in string cache");
+            proto.write(Notification.PACKAGE, notification.getPackage());
+        }
+        final int channelNameIndex = Arrays.binarySearch(stringPool, notification.getChannelName());
+        if (channelNameIndex >= 0) {
+            proto.write(Notification.CHANNEL_NAME_INDEX, channelNameIndex + 1);
+        } else {
+            Slog.w(TAG, "notification channel name (" + notification.getChannelName()
+                    + ") not found in string cache");
+            proto.write(Notification.CHANNEL_NAME, notification.getChannelName());
+        }
+        final int channelIdIndex = Arrays.binarySearch(stringPool, notification.getChannelId());
+        if (channelIdIndex >= 0) {
+            proto.write(Notification.CHANNEL_ID_INDEX, channelIdIndex + 1);
+        } else {
+            Slog.w(TAG, "notification channel id (" + notification.getChannelId()
+                    + ") not found in string cache");
+            proto.write(Notification.CHANNEL_ID, notification.getChannelId());
+        }
+        proto.write(Notification.UID, notification.getUid());
+        proto.write(Notification.USER_ID, notification.getUserId());
+        proto.write(Notification.POSTED_TIME_MS, notification.getPostedTimeMs());
+        proto.write(Notification.TITLE, notification.getTitle());
+        proto.write(Notification.TEXT, notification.getText());
+        writeIcon(proto, notification);
+        proto.end(token);
+    }
+
+    public static void read(InputStream in, NotificationHistory notifications,
+            NotificationHistoryFilter filter) throws IOException {
+        final ProtoInputStream proto = new ProtoInputStream(in);
+        List<String> stringPool = new ArrayList<>();
+        while (true) {
+            switch (proto.nextField()) {
+                case (int) NotificationHistoryProto.STRING_POOL:
+                    stringPool = readStringPool(proto);
+                    break;
+                case (int) NotificationHistoryProto.NOTIFICATION:
+                    readNotification(proto, stringPool, notifications, filter);
+                    break;
+                case ProtoInputStream.NO_MORE_FIELDS:
+                    if (filter.isFiltering()) {
+                        notifications.poolStringsFromNotifications();
+                    } else {
+                        notifications.addPooledStrings(stringPool);
+                    }
+                    return;
+            }
+        }
+    }
+
+    public static void write(OutputStream out, NotificationHistory notifications, int version) {
+        final ProtoOutputStream proto = new ProtoOutputStream(out);
+        proto.write(NotificationHistoryProto.MAJOR_VERSION, version);
+        // String pool should be written before the history itself
+        writeStringPool(proto, notifications);
+
+        List<HistoricalNotification> notificationsToWrite = notifications.getNotificationsToWrite();
+        final int count = notificationsToWrite.size();
+        for (int i = 0; i < count; i++) {
+            writeNotification(proto, notifications.getPooledStringsToWrite(),
+                    notificationsToWrite.get(i));
+        }
+
+        proto.flush();
+    }
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryDatabaseTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryDatabaseTest.java
new file mode 100644 (file)
index 0000000..bcff2f8
--- /dev/null
@@ -0,0 +1,184 @@
+/*
+ * 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 com.android.server.notification;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.NotificationHistory.HistoricalNotification;
+import android.graphics.drawable.Icon;
+import android.os.Handler;
+import android.util.AtomicFile;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.UiServiceTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+@RunWith(AndroidJUnit4.class)
+public class NotificationHistoryDatabaseTest extends UiServiceTestCase {
+
+    File mRootDir;
+    @Mock
+    Handler mFileWriteHandler;
+
+    NotificationHistoryDatabase mDataBase;
+
+    private HistoricalNotification getHistoricalNotification(int index) {
+        return getHistoricalNotification("package" + index, index);
+    }
+
+    private HistoricalNotification getHistoricalNotification(String packageName, int index) {
+        String expectedChannelName = "channelName" + index;
+        String expectedChannelId = "channelId" + index;
+        int expectedUid = 1123456 + index;
+        int expectedUserId = 11 + index;
+        long expectedPostTime = 987654321 + index;
+        String expectedTitle = "title" + index;
+        String expectedText = "text" + index;
+        Icon expectedIcon = Icon.createWithResource(InstrumentationRegistry.getContext(),
+                index);
+
+        return new HistoricalNotification.Builder()
+                .setPackage(packageName)
+                .setChannelName(expectedChannelName)
+                .setChannelId(expectedChannelId)
+                .setUid(expectedUid)
+                .setUserId(expectedUserId)
+                .setPostedTimeMs(expectedPostTime)
+                .setTitle(expectedTitle)
+                .setText(expectedText)
+                .setIcon(expectedIcon)
+                .build();
+    }
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mRootDir = new File(mContext.getFilesDir(), "NotificationHistoryDatabaseTest");
+
+        mDataBase = new NotificationHistoryDatabase(mRootDir);
+        mDataBase.init(mFileWriteHandler);
+    }
+
+    @Test
+    public void testPrune() {
+        int retainDays = 1;
+        for (long i = 10; i >= 5; i--) {
+            File file = mock(File.class);
+            when(file.lastModified()).thenReturn(i);
+            AtomicFile af = new AtomicFile(file);
+            mDataBase.mHistoryFiles.addLast(af);
+        }
+        GregorianCalendar cal = new GregorianCalendar();
+        cal.setTimeInMillis(5);
+        cal.add(Calendar.DATE, -1 * retainDays);
+        for (int i = 5; i >= 0; i--) {
+            File file = mock(File.class);
+            when(file.lastModified()).thenReturn(cal.getTimeInMillis() - i);
+            AtomicFile af = new AtomicFile(file);
+            mDataBase.mHistoryFiles.addLast(af);
+        }
+        mDataBase.prune(retainDays, 10);
+
+        for (AtomicFile file : mDataBase.mHistoryFiles) {
+            assertThat(file.getBaseFile().lastModified() > 0);
+        }
+    }
+
+    @Test
+    public void testOnPackageRemove_posts() {
+        mDataBase.onPackageRemoved("test");
+        verify(mFileWriteHandler, times(1)).post(any());
+    }
+
+    @Test
+    public void testForceWriteToDisk() {
+        mDataBase.forceWriteToDisk();
+        verify(mFileWriteHandler, times(1)).post(any());
+    }
+
+    @Test
+    public void testOnlyOneWriteRunnableInQueue() {
+        when(mFileWriteHandler.hasCallbacks(any())).thenReturn(true);
+        mDataBase.forceWriteToDisk();
+        verify(mFileWriteHandler, never()).post(any());
+    }
+
+    @Test
+    public void testAddNotification() {
+        HistoricalNotification n = getHistoricalNotification(1);
+        HistoricalNotification n2 = getHistoricalNotification(2);
+
+        mDataBase.addNotification(n);
+        assertThat(mDataBase.mBuffer.getNotificationsToWrite()).contains(n);
+        verify(mFileWriteHandler, times(1)).postDelayed(any(), anyLong());
+
+        // second add should not trigger another write
+        mDataBase.addNotification(n2);
+        assertThat(mDataBase.mBuffer.getNotificationsToWrite()).contains(n2);
+        verify(mFileWriteHandler, times(1)).postDelayed(any(), anyLong());
+    }
+
+    @Test
+    public void testReadNotificationHistory_readsAllFiles() throws Exception {
+        for (long i = 10; i >= 5; i--) {
+            AtomicFile af = mock(AtomicFile.class);
+            mDataBase.mHistoryFiles.addLast(af);
+        }
+
+        mDataBase.readNotificationHistory();
+
+        for (AtomicFile file : mDataBase.mHistoryFiles) {
+            verify(file, times(1)).openRead();
+        }
+    }
+
+    @Test
+    public void testReadNotificationHistory_withNumFilterDoesNotReadExtraFiles() throws Exception {
+        AtomicFile af = mock(AtomicFile.class);
+        when(af.getBaseFile()).thenReturn(new File(mRootDir, "af"));
+        mDataBase.mHistoryFiles.addLast(af);
+
+        AtomicFile af2 = mock(AtomicFile.class);
+        when(af2.getBaseFile()).thenReturn(new File(mRootDir, "af2"));
+        mDataBase.mHistoryFiles.addLast(af2);
+
+        mDataBase.readNotificationHistory(null, null, 0);
+
+        verify(af, times(1)).openRead();
+        verify(af2, never()).openRead();
+    }
+
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryFilterTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryFilterTest.java
new file mode 100644 (file)
index 0000000..10bfcf1
--- /dev/null
@@ -0,0 +1,159 @@
+/*
+ * 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 com.android.server.notification;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.NotificationHistory;
+import android.app.NotificationHistory.HistoricalNotification;
+import android.graphics.drawable.Icon;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.UiServiceTestCase;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class NotificationHistoryFilterTest extends UiServiceTestCase {
+
+    private HistoricalNotification getHistoricalNotification(int index) {
+        return getHistoricalNotification("package" + index, "channelId" + index, index);
+    }
+    private HistoricalNotification getHistoricalNotification(String pkg, int index) {
+        return getHistoricalNotification(pkg, "channelId" + index, index);
+    }
+
+    private HistoricalNotification getHistoricalNotification(String packageName, String channelId,
+            int index) {
+        String expectedChannelName = "channelName" + index;
+        int expectedUid = 1123456 + index;
+        int expectedUserId = 11 + index;
+        long expectedPostTime = 987654321 + index;
+        String expectedTitle = "title" + index;
+        String expectedText = "text" + index;
+        Icon expectedIcon = Icon.createWithResource(InstrumentationRegistry.getContext(),
+                index);
+
+        return new HistoricalNotification.Builder()
+                .setPackage(packageName)
+                .setChannelName(expectedChannelName)
+                .setChannelId(channelId)
+                .setUid(expectedUid)
+                .setUserId(expectedUserId)
+                .setPostedTimeMs(expectedPostTime)
+                .setTitle(expectedTitle)
+                .setText(expectedText)
+                .setIcon(expectedIcon)
+                .build();
+    }
+
+    @Test
+    public void testBuilder() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setChannel("pkg", "channel")
+                .setMaxNotifications(3)
+                .build();
+
+        assertThat(filter.getPackage()).isEqualTo("pkg");
+        assertThat(filter.getChannel()).isEqualTo("channel");
+        assertThat(filter.getMaxNotifications()).isEqualTo(3);
+    }
+
+    @Test
+    public void testMatchesCountFilter() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setMaxNotifications(3)
+                .build();
+
+        NotificationHistory history = new NotificationHistory();
+        assertThat(filter.matchesCountFilter(history)).isTrue();
+        history.addNotificationToWrite(getHistoricalNotification(1));
+        assertThat(filter.matchesCountFilter(history)).isTrue();
+        history.addNotificationToWrite(getHistoricalNotification(2));
+        assertThat(filter.matchesCountFilter(history)).isTrue();
+        history.addNotificationToWrite(getHistoricalNotification(3));
+        assertThat(filter.matchesCountFilter(history)).isFalse();
+    }
+
+    @Test
+    public void testMatchesCountFilter_noCountFilter() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .build();
+
+        NotificationHistory history = new NotificationHistory();
+        assertThat(filter.matchesCountFilter(history)).isTrue();
+        history.addNotificationToWrite(getHistoricalNotification(1));
+        assertThat(filter.matchesCountFilter(history)).isTrue();
+    }
+
+    @Test
+    public void testMatchesPackageAndChannelFilter_pkgOnly() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setPackage("pkg")
+                .build();
+
+        HistoricalNotification hnMatches = getHistoricalNotification("pkg", 1);
+        assertThat(filter.matchesPackageAndChannelFilter(hnMatches)).isTrue();
+        HistoricalNotification hnMatches2 = getHistoricalNotification("pkg", 2);
+        assertThat(filter.matchesPackageAndChannelFilter(hnMatches2)).isTrue();
+
+        HistoricalNotification hnNoMatch = getHistoricalNotification("pkg2", 2);
+        assertThat(filter.matchesPackageAndChannelFilter(hnNoMatch)).isFalse();
+    }
+
+    @Test
+    public void testMatchesPackageAndChannelFilter_channelAlso() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setChannel("pkg", "channel")
+                .build();
+
+        HistoricalNotification hn1 = getHistoricalNotification("pkg", 1);
+        assertThat(filter.matchesPackageAndChannelFilter(hn1)).isFalse();
+
+        HistoricalNotification hn2 = getHistoricalNotification("pkg", "channel", 1);
+        assertThat(filter.matchesPackageAndChannelFilter(hn2)).isTrue();
+
+        HistoricalNotification hn3 = getHistoricalNotification("pkg2", "channel", 1);
+        assertThat(filter.matchesPackageAndChannelFilter(hn3)).isFalse();
+    }
+
+    @Test
+    public void testIsFiltering() {
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .build();
+        assertThat(filter.isFiltering()).isFalse();
+
+        filter = new NotificationHistoryFilter.Builder()
+                .setPackage("pkg")
+                .build();
+        assertThat(filter.isFiltering()).isTrue();
+
+        filter = new NotificationHistoryFilter.Builder()
+                .setChannel("pkg", "channel")
+                .build();
+        assertThat(filter.isFiltering()).isTrue();
+
+        filter = new NotificationHistoryFilter.Builder()
+                .setMaxNotifications(5)
+                .build();
+        assertThat(filter.isFiltering()).isTrue();
+    }
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryProtoHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryProtoHelperTest.java
new file mode 100644 (file)
index 0000000..458117d
--- /dev/null
@@ -0,0 +1,297 @@
+/*
+ * 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 com.android.server.notification;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.NotificationHistory;
+import android.app.NotificationHistory.HistoricalNotification;
+import android.graphics.drawable.Icon;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.UiServiceTestCase;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class NotificationHistoryProtoHelperTest extends UiServiceTestCase {
+
+    private HistoricalNotification getHistoricalNotification(int index) {
+        return getHistoricalNotification("package" + index, index);
+    }
+
+    private HistoricalNotification getHistoricalNotification(String packageName, int index) {
+        String expectedChannelName = "channelName" + index;
+        String expectedChannelId = "channelId" + index;
+        int expectedUid = 1123456 + index;
+        int expectedUserId = 11 + index;
+        long expectedPostTime = 987654321 + index;
+        String expectedTitle = "title" + index;
+        String expectedText = "text" + index;
+        Icon expectedIcon = Icon.createWithResource(InstrumentationRegistry.getContext(),
+                index);
+
+        return new HistoricalNotification.Builder()
+                .setPackage(packageName)
+                .setChannelName(expectedChannelName)
+                .setChannelId(expectedChannelId)
+                .setUid(expectedUid)
+                .setUserId(expectedUserId)
+                .setPostedTimeMs(expectedPostTime)
+                .setTitle(expectedTitle)
+                .setText(expectedText)
+                .setIcon(expectedIcon)
+                .build();
+    }
+
+    @Test
+    public void testReadWriteNotifications() throws Exception {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        // loops backwards just to maintain the post time newest -> oldest expectation
+        for (int i = 10; i >= 1; i--) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedEntries.add(n);
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        NotificationHistory actualHistory = new NotificationHistory();
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                new NotificationHistoryFilter.Builder().build());
+
+        assertThat(actualHistory.getHistoryCount()).isEqualTo(history.getHistoryCount());
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+    }
+
+    @Test
+    public void testReadWriteNotifications_stringFieldsPersistedEvenIfNoPool() throws Exception {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        // loops backwards just to maintain the post time newest -> oldest expectation
+        for (int i = 10; i >= 1; i--) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedEntries.add(n);
+            history.addNotificationToWrite(n);
+        }
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        NotificationHistory actualHistory = new NotificationHistory();
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                new NotificationHistoryFilter.Builder().build());
+
+        assertThat(actualHistory.getHistoryCount()).isEqualTo(history.getHistoryCount());
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+    }
+
+    @Test
+    public void testReadNotificationsWithPkgFilter() throws Exception {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        Set<String> expectedStrings = new HashSet<>();
+        // loops backwards just to maintain the post time newest -> oldest expectation
+        for (int i = 10; i >= 1; i--) {
+            HistoricalNotification n =
+                    getHistoricalNotification((i % 2 == 0) ? "pkgEven" : "pkgOdd", i);
+
+            if (i % 2 == 0) {
+                expectedStrings.add(n.getPackage());
+                expectedStrings.add(n.getChannelName());
+                expectedStrings.add(n.getChannelId());
+                expectedEntries.add(n);
+            }
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        NotificationHistory actualHistory = new NotificationHistory();
+
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setPackage("pkgEven")
+                .build();
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                filter);
+
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+        assertThat(Arrays.asList(actualHistory.getPooledStringsToWrite()))
+                .containsExactlyElementsIn(expectedStrings);
+    }
+
+    @Test
+    public void testReadNotificationsWithNumberFilter() throws Exception {
+        int maxCount = 3;
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        Set<String> expectedStrings = new HashSet<>();
+        for (int i = 1; i < 10; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+
+            if (i <= maxCount) {
+                expectedStrings.add(n.getPackage());
+                expectedStrings.add(n.getChannelName());
+                expectedStrings.add(n.getChannelId());
+                expectedEntries.add(n);
+            }
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        NotificationHistory actualHistory = new NotificationHistory();
+
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setMaxNotifications(maxCount)
+                .build();
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                filter);
+
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+        assertThat(Arrays.asList(actualHistory.getPooledStringsToWrite()))
+                .containsExactlyElementsIn(expectedStrings);
+    }
+
+    @Test
+    public void testReadNotificationsWithNumberFilter_preExistingNotifs() throws Exception {
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        Set<String> expectedStrings = new HashSet<>();
+        int maxCount = 3;
+
+        NotificationHistory history = new NotificationHistory();
+        HistoricalNotification old1 = getHistoricalNotification(40);
+        history.addNotificationToWrite(old1);
+        expectedEntries.add(old1);
+
+        HistoricalNotification old2 = getHistoricalNotification(50);
+        history.addNotificationToWrite(old2);
+        expectedEntries.add(old2);
+        history.poolStringsFromNotifications();
+        expectedStrings.addAll(Arrays.asList(history.getPooledStringsToWrite()));
+
+        for (int i = 1; i < 10; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+
+            if (i <= (maxCount - 2)) {
+                expectedStrings.add(n.getPackage());
+                expectedStrings.add(n.getChannelName());
+                expectedStrings.add(n.getChannelId());
+                expectedEntries.add(n);
+            }
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        NotificationHistory actualHistory = new NotificationHistory();
+
+        NotificationHistoryFilter filter = new NotificationHistoryFilter.Builder()
+                .setMaxNotifications(maxCount)
+                .build();
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                filter);
+
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+        assertThat(Arrays.asList(actualHistory.getPooledStringsToWrite()))
+                .containsExactlyElementsIn(expectedStrings);
+    }
+
+    @Test
+    public void testReadMergeIntoExistingHistory() throws Exception {
+        NotificationHistory history = new NotificationHistory();
+
+        List<HistoricalNotification> expectedEntries = new ArrayList<>();
+        Set<String> expectedStrings = new HashSet<>();
+        for (int i = 1; i < 10; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedEntries.add(n);
+            expectedStrings.add(n.getPackage());
+            expectedStrings.add(n.getChannelName());
+            expectedStrings.add(n.getChannelId());
+            history.addNotificationToWrite(n);
+        }
+        history.poolStringsFromNotifications();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        NotificationHistoryProtoHelper.write(baos, history, 1);
+
+        // set up pre-existing notification history, as though read from a different file
+        NotificationHistory actualHistory = new NotificationHistory();
+        for (int i = 10; i < 20; i++) {
+            HistoricalNotification n = getHistoricalNotification(i);
+            expectedEntries.add(n);
+            expectedStrings.add(n.getPackage());
+            expectedStrings.add(n.getChannelName());
+            expectedStrings.add(n.getChannelId());
+            actualHistory.addNotificationToWrite(n);
+        }
+        actualHistory.poolStringsFromNotifications();
+
+        NotificationHistoryProtoHelper.read(
+                new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray())),
+                actualHistory,
+                new NotificationHistoryFilter.Builder().build());
+
+        // Make sure history contains the original and new entries
+        assertThat(actualHistory.getNotificationsToWrite())
+                .containsExactlyElementsIn(expectedEntries);
+        assertThat(Arrays.asList(actualHistory.getPooledStringsToWrite()))
+                .containsExactlyElementsIn(expectedStrings);
+    }
+}