From e261db3fce5496b8093982c9f87128c72b133d69 Mon Sep 17 00:00:00 2001 From: Julia Reynolds Date: Mon, 21 Oct 2019 11:37:35 -0400 Subject: [PATCH] Add ability to write and read Notification history. Bug: 137396965 Test: atest Change-Id: I6bfdd0f6906dcf9c58b592e03bef335c4920d94d --- core/java/android/app/NotificationHistory.aidl | 19 + core/java/android/app/NotificationHistory.java | 506 +++++++++++++++++++++ .../src/android/app/NotificationHistoryTest.java | 232 ++++++++++ .../notification/NotificationHistoryDatabase.java | 300 ++++++++++++ .../notification/NotificationHistoryFilter.java | 125 +++++ .../NotificationHistoryProtoHelper.java | 321 +++++++++++++ .../NotificationHistoryDatabaseTest.java | 184 ++++++++ .../NotificationHistoryFilterTest.java | 159 +++++++ .../NotificationHistoryProtoHelperTest.java | 297 ++++++++++++ 9 files changed, 2143 insertions(+) create mode 100644 core/java/android/app/NotificationHistory.aidl create mode 100644 core/java/android/app/NotificationHistory.java create mode 100644 core/tests/coretests/src/android/app/NotificationHistoryTest.java create mode 100644 services/core/java/com/android/server/notification/NotificationHistoryDatabase.java create mode 100644 services/core/java/com/android/server/notification/NotificationHistoryFilter.java create mode 100644 services/core/java/com/android/server/notification/NotificationHistoryProtoHelper.java create mode 100644 services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryDatabaseTest.java create mode 100644 services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryFilterTest.java create mode 100644 services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryProtoHelperTest.java diff --git a/core/java/android/app/NotificationHistory.aidl b/core/java/android/app/NotificationHistory.aidl new file mode 100644 index 000000000000..8150e743335a --- /dev/null +++ b/core/java/android/app/NotificationHistory.aidl @@ -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 index 000000000000..c35246b49395 --- /dev/null +++ b/core/java/android/app/NotificationHistory.java @@ -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 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()); + } + } + + /** + * 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 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 CREATOR + = new Creator() { + @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 index 000000000000..08595bb43e06 --- /dev/null +++ b/core/tests/coretests/src/android/app/NotificationHistoryTest.java @@ -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 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 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 postRemoveExpectedEntries = new ArrayList<>(); + List 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 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 index 000000000000..99b1ef4a1d2e --- /dev/null +++ b/services/core/java/com/android/server/notification/NotificationHistoryDatabase.java @@ -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 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 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 index 000000000000..c3b2e73b5354 --- /dev/null +++ b/services/core/java/com/android/server/notification/NotificationHistoryFilter.java @@ -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 index 000000000000..2831d37ed70b --- /dev/null +++ b/services/core/java/com/android/server/notification/NotificationHistoryProtoHelper.java @@ -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 readStringPool(ProtoInputStream proto) throws IOException { + final long token = proto.start(NotificationHistoryProto.STRING_POOL); + List 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 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 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 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 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 index 000000000000..bcff2f81f805 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryDatabaseTest.java @@ -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 index 000000000000..10bfcf12c89f --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryFilterTest.java @@ -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 index 000000000000..458117d50784 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationHistoryProtoHelperTest.java @@ -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 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 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 expectedEntries = new ArrayList<>(); + Set 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 expectedEntries = new ArrayList<>(); + Set 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 expectedEntries = new ArrayList<>(); + Set 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 expectedEntries = new ArrayList<>(); + Set 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); + } +} -- 2.11.0