Merge "Add persistence of ConversationInfo during device reboot."
diff --git a/services/people/java/com/android/server/people/data/AbstractProtoDiskReadWriter.java b/services/people/java/com/android/server/people/data/AbstractProtoDiskReadWriter.java
new file mode 100644
index 0000000..203e980
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/AbstractProtoDiskReadWriter.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2020 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.people.data;
+
+import android.annotation.MainThread;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.WorkerThread;
+import android.text.format.DateUtils;
+import android.util.ArrayMap;
+import android.util.AtomicFile;
+import android.util.Slog;
+import android.util.proto.ProtoInputStream;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Base class for reading and writing protobufs on disk from a root directory. Callers should
+ * ensure that the root directory is unlocked before doing I/O operations using this class.
+ *
+ * @param <T> is the data class representation of a protobuf.
+ */
+abstract class AbstractProtoDiskReadWriter<T> {
+
+ private static final String TAG = AbstractProtoDiskReadWriter.class.getSimpleName();
+ private static final long SHUTDOWN_DISK_WRITE_TIMEOUT = 5L * DateUtils.SECOND_IN_MILLIS;
+
+ private final File mRootDir;
+ private final ScheduledExecutorService mScheduledExecutorService;
+ private final long mWriteDelayMs;
+
+ @GuardedBy("this")
+ private ScheduledFuture<?> mScheduledFuture;
+
+ @GuardedBy("this")
+ private Map<String, T> mScheduledFileDataMap = new ArrayMap<>();
+
+ /**
+ * Child class shall provide a {@link ProtoStreamWriter} to facilitate the writing of data as a
+ * protobuf on disk.
+ */
+ abstract ProtoStreamWriter<T> protoStreamWriter();
+
+ /**
+ * Child class shall provide a {@link ProtoStreamReader} to facilitate the reading of protobuf
+ * data on disk.
+ */
+ abstract ProtoStreamReader<T> protoStreamReader();
+
+ AbstractProtoDiskReadWriter(@NonNull File rootDir, long writeDelayMs,
+ @NonNull ScheduledExecutorService scheduledExecutorService) {
+ mRootDir = rootDir;
+ mWriteDelayMs = writeDelayMs;
+ mScheduledExecutorService = scheduledExecutorService;
+ }
+
+ @WorkerThread
+ void delete(@NonNull String fileName) {
+ final File file = getFile(fileName);
+ if (!file.exists()) {
+ return;
+ }
+ if (!file.delete()) {
+ Slog.e(TAG, "Failed to delete file: " + file.getPath());
+ }
+ }
+
+ @WorkerThread
+ void writeTo(@NonNull String fileName, @NonNull T data) {
+ final File file = getFile(fileName);
+ final AtomicFile atomicFile = new AtomicFile(file);
+
+ FileOutputStream fileOutputStream = null;
+ try {
+ fileOutputStream = atomicFile.startWrite();
+ } catch (IOException e) {
+ Slog.e(TAG, "Failed to write to protobuf file.", e);
+ return;
+ }
+
+ try {
+ final ProtoOutputStream protoOutputStream = new ProtoOutputStream(fileOutputStream);
+ protoStreamWriter().write(protoOutputStream, data);
+ protoOutputStream.flush();
+ atomicFile.finishWrite(fileOutputStream);
+ fileOutputStream = null;
+ } finally {
+ // When fileInputStream is null (successful write), this will no-op.
+ atomicFile.failWrite(fileOutputStream);
+ }
+ }
+
+ @WorkerThread
+ @Nullable
+ T read(@NonNull String fileName) {
+ File[] files = mRootDir.listFiles(
+ pathname -> pathname.isFile() && pathname.getName().equals(fileName));
+ if (files == null || files.length == 0) {
+ return null;
+ } else if (files.length > 1) {
+ // This can't possibly happen, but sanity check.
+ Slog.w(TAG, "Found multiple files with the same name: " + Arrays.toString(files));
+ }
+ return parseFile(files[0]);
+ }
+
+ /**
+ * Reads all files in directory and returns a map with file names as keys and parsed file
+ * contents as values.
+ */
+ @WorkerThread
+ @Nullable
+ Map<String, T> readAll() {
+ File[] files = mRootDir.listFiles(File::isFile);
+ if (files == null) {
+ return null;
+ }
+
+ Map<String, T> results = new ArrayMap<>();
+ for (File file : files) {
+ T result = parseFile(file);
+ if (result != null) {
+ results.put(file.getName(), result);
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Schedules the specified data to be flushed to a file in the future. Subsequent
+ * calls for the same file before the flush occurs will replace the previous data but will not
+ * reset when the flush will occur. All unique files will be flushed at the same time.
+ */
+ @MainThread
+ synchronized void scheduleSave(@NonNull String fileName, @NonNull T data) {
+ mScheduledFileDataMap.put(fileName, data);
+
+ if (mScheduledExecutorService.isShutdown()) {
+ Slog.e(TAG, "Worker is shutdown, failed to schedule data saving.");
+ return;
+ }
+
+ // Skip scheduling another flush when one is pending.
+ if (mScheduledFuture != null) {
+ return;
+ }
+
+ mScheduledFuture = mScheduledExecutorService.schedule(this::flushScheduledData,
+ mWriteDelayMs, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Saves specified data immediately on a background thread, and blocks until its completed. This
+ * is useful for when device is powering off.
+ */
+ @MainThread
+ synchronized void saveImmediately(@NonNull String fileName, @NonNull T data) {
+ if (mScheduledExecutorService.isShutdown()) {
+ return;
+ }
+ // Cancel existing future.
+ if (mScheduledFuture != null) {
+
+ // We shouldn't need to interrupt as this method and threaded task
+ // #flushScheduledData are both synchronized.
+ mScheduledFuture.cancel(true);
+ }
+
+ mScheduledFileDataMap.put(fileName, data);
+ // Submit flush and blocks until it completes. Blocking will prevent the device from
+ // shutting down before flushing completes.
+ Future<?> future = mScheduledExecutorService.submit(this::flushScheduledData);
+ try {
+ future.get(SHUTDOWN_DISK_WRITE_TIMEOUT, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ Slog.e(TAG, "Failed to save data immediately.", e);
+ }
+ }
+
+ @WorkerThread
+ private synchronized void flushScheduledData() {
+ if (mScheduledFileDataMap.isEmpty()) {
+ mScheduledFuture = null;
+ return;
+ }
+ for (String fileName : mScheduledFileDataMap.keySet()) {
+ T data = mScheduledFileDataMap.remove(fileName);
+ writeTo(fileName, data);
+ }
+ mScheduledFuture = null;
+ }
+
+ @WorkerThread
+ @Nullable
+ private T parseFile(@NonNull File file) {
+ final AtomicFile atomicFile = new AtomicFile(file);
+ try (FileInputStream fileInputStream = atomicFile.openRead()) {
+ final ProtoInputStream protoInputStream = new ProtoInputStream(fileInputStream);
+ return protoStreamReader().read(protoInputStream);
+ } catch (IOException e) {
+ Slog.e(TAG, "Failed to parse protobuf file.", e);
+ }
+ return null;
+ }
+
+ @NonNull
+ private File getFile(String fileName) {
+ return new File(mRootDir, fileName);
+ }
+
+ /**
+ * {@code ProtoStreamWriter} writes {@code T} fields to {@link ProtoOutputStream}.
+ *
+ * @param <T> is the data class representation of a protobuf.
+ */
+ interface ProtoStreamWriter<T> {
+
+ /**
+ * Writes {@code T} to {@link ProtoOutputStream}.
+ */
+ void write(@NonNull ProtoOutputStream protoOutputStream, @NonNull T data);
+ }
+
+ /**
+ * {@code ProtoStreamReader} reads {@link ProtoInputStream} and translate it to {@code T}.
+ *
+ * @param <T> is the data class representation of a protobuf.
+ */
+ interface ProtoStreamReader<T> {
+ /**
+ * Reads {@link ProtoInputStream} and translates it to {@code T}.
+ */
+ @Nullable
+ T read(@NonNull ProtoInputStream protoInputStream);
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/ConversationInfo.java b/services/people/java/com/android/server/people/data/ConversationInfo.java
index b60ed3e..ce35366 100644
--- a/services/people/java/com/android/server/people/data/ConversationInfo.java
+++ b/services/people/java/com/android/server/people/data/ConversationInfo.java
@@ -20,12 +20,18 @@
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.LocusId;
+import android.content.LocusIdProto;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutInfo.ShortcutFlags;
import android.net.Uri;
+import android.util.Slog;
+import android.util.proto.ProtoInputStream;
+import android.util.proto.ProtoOutputStream;
import com.android.internal.util.Preconditions;
+import com.android.server.people.ConversationInfoProto;
+import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Objects;
@@ -35,6 +41,8 @@
*/
public class ConversationInfo {
+ private static final String TAG = ConversationInfo.class.getSimpleName();
+
private static final int FLAG_IMPORTANT = 1;
private static final int FLAG_NOTIFICATION_SILENCED = 1 << 1;
@@ -241,6 +249,72 @@
return (mConversationFlags & flags) == flags;
}
+ /** Writes field members to {@link ProtoOutputStream}. */
+ void writeToProto(@NonNull ProtoOutputStream protoOutputStream) {
+ protoOutputStream.write(ConversationInfoProto.SHORTCUT_ID, mShortcutId);
+ if (mLocusId != null) {
+ long locusIdToken = protoOutputStream.start(ConversationInfoProto.LOCUS_ID_PROTO);
+ protoOutputStream.write(LocusIdProto.LOCUS_ID, mLocusId.getId());
+ protoOutputStream.end(locusIdToken);
+ }
+ if (mContactUri != null) {
+ protoOutputStream.write(ConversationInfoProto.CONTACT_URI, mContactUri.toString());
+ }
+ if (mNotificationChannelId != null) {
+ protoOutputStream.write(ConversationInfoProto.NOTIFICATION_CHANNEL_ID,
+ mNotificationChannelId);
+ }
+ protoOutputStream.write(ConversationInfoProto.SHORTCUT_FLAGS, mShortcutFlags);
+ protoOutputStream.write(ConversationInfoProto.CONVERSATION_FLAGS, mConversationFlags);
+ }
+
+ /** Reads from {@link ProtoInputStream} and constructs a {@link ConversationInfo}. */
+ @NonNull
+ static ConversationInfo readFromProto(@NonNull ProtoInputStream protoInputStream)
+ throws IOException {
+ ConversationInfo.Builder builder = new ConversationInfo.Builder();
+ while (protoInputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
+ switch (protoInputStream.getFieldNumber()) {
+ case (int) ConversationInfoProto.SHORTCUT_ID:
+ builder.setShortcutId(
+ protoInputStream.readString(ConversationInfoProto.SHORTCUT_ID));
+ break;
+ case (int) ConversationInfoProto.LOCUS_ID_PROTO:
+ long locusIdToken = protoInputStream.start(
+ ConversationInfoProto.LOCUS_ID_PROTO);
+ while (protoInputStream.nextField()
+ != ProtoInputStream.NO_MORE_FIELDS) {
+ if (protoInputStream.getFieldNumber() == (int) LocusIdProto.LOCUS_ID) {
+ builder.setLocusId(new LocusId(
+ protoInputStream.readString(LocusIdProto.LOCUS_ID)));
+ }
+ }
+ protoInputStream.end(locusIdToken);
+ break;
+ case (int) ConversationInfoProto.CONTACT_URI:
+ builder.setContactUri(Uri.parse(protoInputStream.readString(
+ ConversationInfoProto.CONTACT_URI)));
+ break;
+ case (int) ConversationInfoProto.NOTIFICATION_CHANNEL_ID:
+ builder.setNotificationChannelId(protoInputStream.readString(
+ ConversationInfoProto.NOTIFICATION_CHANNEL_ID));
+ break;
+ case (int) ConversationInfoProto.SHORTCUT_FLAGS:
+ builder.setShortcutFlags(protoInputStream.readInt(
+ ConversationInfoProto.SHORTCUT_FLAGS));
+ break;
+ case (int) ConversationInfoProto.CONVERSATION_FLAGS:
+ builder.setConversationFlags(protoInputStream.readInt(
+ ConversationInfoProto.CONVERSATION_FLAGS));
+ break;
+ default:
+ Slog.w(TAG, "Could not read undefined field: "
+ + protoInputStream.getFieldNumber());
+ }
+ }
+ return builder.build();
+ }
+
/**
* Builder class for {@link ConversationInfo} objects.
*/
diff --git a/services/people/java/com/android/server/people/data/ConversationStore.java b/services/people/java/com/android/server/people/data/ConversationStore.java
index 3649921..ea36d38 100644
--- a/services/people/java/com/android/server/people/data/ConversationStore.java
+++ b/services/people/java/com/android/server/people/data/ConversationStore.java
@@ -16,58 +16,124 @@
package com.android.server.people.data;
+import android.annotation.MainThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
+import android.annotation.WorkerThread;
import android.content.LocusId;
import android.net.Uri;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
import android.util.ArrayMap;
+import android.util.Slog;
+import android.util.proto.ProtoInputStream;
+import com.android.internal.annotations.GuardedBy;
+import com.android.server.people.ConversationInfosProto;
+
+import com.google.android.collect.Lists;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
-/** The store that stores and accesses the conversations data for a package. */
+/**
+ * The store that stores and accesses the conversations data for a package.
+ */
class ConversationStore {
+ private static final String TAG = ConversationStore.class.getSimpleName();
+
+ private static final String CONVERSATIONS_FILE_NAME = "conversations";
+
+ private static final long DISK_WRITE_DELAY = 2L * DateUtils.MINUTE_IN_MILLIS;
+
// Shortcut ID -> Conversation Info
+ @GuardedBy("this")
private final Map<String, ConversationInfo> mConversationInfoMap = new ArrayMap<>();
// Locus ID -> Shortcut ID
+ @GuardedBy("this")
private final Map<LocusId, String> mLocusIdToShortcutIdMap = new ArrayMap<>();
// Contact URI -> Shortcut ID
+ @GuardedBy("this")
private final Map<Uri, String> mContactUriToShortcutIdMap = new ArrayMap<>();
// Phone Number -> Shortcut ID
+ @GuardedBy("this")
private final Map<String, String> mPhoneNumberToShortcutIdMap = new ArrayMap<>();
// Notification Channel ID -> Shortcut ID
+ @GuardedBy("this")
private final Map<String, String> mNotifChannelIdToShortcutIdMap = new ArrayMap<>();
- void addOrUpdate(@NonNull ConversationInfo conversationInfo) {
- mConversationInfoMap.put(conversationInfo.getShortcutId(), conversationInfo);
+ private final ScheduledExecutorService mScheduledExecutorService;
+ private final File mPackageDir;
+ private final ContactsQueryHelper mHelper;
- LocusId locusId = conversationInfo.getLocusId();
- if (locusId != null) {
- mLocusIdToShortcutIdMap.put(locusId, conversationInfo.getShortcutId());
- }
+ private ConversationInfosProtoDiskReadWriter mConversationInfosProtoDiskReadWriter;
- Uri contactUri = conversationInfo.getContactUri();
- if (contactUri != null) {
- mContactUriToShortcutIdMap.put(contactUri, conversationInfo.getShortcutId());
- }
+ ConversationStore(@NonNull File packageDir,
+ @NonNull ScheduledExecutorService scheduledExecutorService,
+ @NonNull ContactsQueryHelper helper) {
+ mScheduledExecutorService = scheduledExecutorService;
+ mPackageDir = packageDir;
+ mHelper = helper;
+ }
- String phoneNumber = conversationInfo.getContactPhoneNumber();
- if (phoneNumber != null) {
- mPhoneNumberToShortcutIdMap.put(phoneNumber, conversationInfo.getShortcutId());
- }
+ /**
+ * Loads conversations from disk to memory in a background thread. This should be called
+ * after the device powers on and the user has been unlocked.
+ */
+ @MainThread
+ void loadConversationsFromDisk() {
+ mScheduledExecutorService.submit(() -> {
+ synchronized (this) {
+ ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
+ getConversationInfosProtoDiskReadWriter();
+ if (conversationInfosProtoDiskReadWriter == null) {
+ return;
+ }
+ List<ConversationInfo> conversationsOnDisk =
+ conversationInfosProtoDiskReadWriter.read(CONVERSATIONS_FILE_NAME);
+ if (conversationsOnDisk == null) {
+ return;
+ }
+ for (ConversationInfo conversationInfo : conversationsOnDisk) {
+ conversationInfo = restoreConversationPhoneNumber(conversationInfo);
+ updateConversationsInMemory(conversationInfo);
+ }
+ }
+ });
+ }
- String notifChannelId = conversationInfo.getNotificationChannelId();
- if (notifChannelId != null) {
- mNotifChannelIdToShortcutIdMap.put(notifChannelId, conversationInfo.getShortcutId());
+ /**
+ * Immediately flushes current conversations to disk. This should be called when device is
+ * powering off.
+ */
+ @MainThread
+ synchronized void saveConversationsToDisk() {
+ ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
+ getConversationInfosProtoDiskReadWriter();
+ if (conversationInfosProtoDiskReadWriter != null) {
+ conversationInfosProtoDiskReadWriter.saveConversationsImmediately(
+ new ArrayList<>(mConversationInfoMap.values()));
}
}
- void deleteConversation(@NonNull String shortcutId) {
+ @MainThread
+ synchronized void addOrUpdate(@NonNull ConversationInfo conversationInfo) {
+ updateConversationsInMemory(conversationInfo);
+ scheduleUpdateConversationsOnDisk();
+ }
+
+ @MainThread
+ synchronized void deleteConversation(@NonNull String shortcutId) {
ConversationInfo conversationInfo = mConversationInfoMap.remove(shortcutId);
if (conversationInfo == null) {
return;
@@ -92,31 +158,32 @@
if (notifChannelId != null) {
mNotifChannelIdToShortcutIdMap.remove(notifChannelId);
}
+ scheduleUpdateConversationsOnDisk();
}
- void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
+ synchronized void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
for (ConversationInfo ci : mConversationInfoMap.values()) {
consumer.accept(ci);
}
}
@Nullable
- ConversationInfo getConversation(@Nullable String shortcutId) {
+ synchronized ConversationInfo getConversation(@Nullable String shortcutId) {
return shortcutId != null ? mConversationInfoMap.get(shortcutId) : null;
}
@Nullable
- ConversationInfo getConversationByLocusId(@NonNull LocusId locusId) {
+ synchronized ConversationInfo getConversationByLocusId(@NonNull LocusId locusId) {
return getConversation(mLocusIdToShortcutIdMap.get(locusId));
}
@Nullable
- ConversationInfo getConversationByContactUri(@NonNull Uri contactUri) {
+ synchronized ConversationInfo getConversationByContactUri(@NonNull Uri contactUri) {
return getConversation(mContactUriToShortcutIdMap.get(contactUri));
}
@Nullable
- ConversationInfo getConversationByPhoneNumber(@NonNull String phoneNumber) {
+ synchronized ConversationInfo getConversationByPhoneNumber(@NonNull String phoneNumber) {
return getConversation(mPhoneNumberToShortcutIdMap.get(phoneNumber));
}
@@ -124,4 +191,140 @@
ConversationInfo getConversationByNotificationChannelId(@NonNull String notifChannelId) {
return getConversation(mNotifChannelIdToShortcutIdMap.get(notifChannelId));
}
+
+ @MainThread
+ private synchronized void updateConversationsInMemory(
+ @NonNull ConversationInfo conversationInfo) {
+ mConversationInfoMap.put(conversationInfo.getShortcutId(), conversationInfo);
+
+ LocusId locusId = conversationInfo.getLocusId();
+ if (locusId != null) {
+ mLocusIdToShortcutIdMap.put(locusId, conversationInfo.getShortcutId());
+ }
+
+ Uri contactUri = conversationInfo.getContactUri();
+ if (contactUri != null) {
+ mContactUriToShortcutIdMap.put(contactUri, conversationInfo.getShortcutId());
+ }
+
+ String phoneNumber = conversationInfo.getContactPhoneNumber();
+ if (phoneNumber != null) {
+ mPhoneNumberToShortcutIdMap.put(phoneNumber, conversationInfo.getShortcutId());
+ }
+
+ String notifChannelId = conversationInfo.getNotificationChannelId();
+ if (notifChannelId != null) {
+ mNotifChannelIdToShortcutIdMap.put(notifChannelId, conversationInfo.getShortcutId());
+ }
+ }
+
+ /** Schedules a dump of all conversations onto disk, overwriting existing values. */
+ @MainThread
+ private synchronized void scheduleUpdateConversationsOnDisk() {
+ ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
+ getConversationInfosProtoDiskReadWriter();
+ if (conversationInfosProtoDiskReadWriter != null) {
+ conversationInfosProtoDiskReadWriter.scheduleConversationsSave(
+ new ArrayList<>(mConversationInfoMap.values()));
+ }
+ }
+
+ @Nullable
+ private ConversationInfosProtoDiskReadWriter getConversationInfosProtoDiskReadWriter() {
+ if (!mPackageDir.exists()) {
+ Slog.e(TAG, "Package data directory does not exist: " + mPackageDir.getAbsolutePath());
+ return null;
+ }
+ if (mConversationInfosProtoDiskReadWriter == null) {
+ mConversationInfosProtoDiskReadWriter = new ConversationInfosProtoDiskReadWriter(
+ mPackageDir, CONVERSATIONS_FILE_NAME, DISK_WRITE_DELAY,
+ mScheduledExecutorService);
+ }
+ return mConversationInfosProtoDiskReadWriter;
+ }
+
+ /**
+ * Conversation's phone number is not saved on disk, so it has to be fetched.
+ */
+ @WorkerThread
+ private ConversationInfo restoreConversationPhoneNumber(
+ @NonNull ConversationInfo conversationInfo) {
+ if (conversationInfo.getContactUri() != null) {
+ if (mHelper.query(conversationInfo.getContactUri().toString())) {
+ String phoneNumber = mHelper.getPhoneNumber();
+ if (!TextUtils.isEmpty(phoneNumber)) {
+ conversationInfo = new ConversationInfo.Builder(
+ conversationInfo).setContactPhoneNumber(
+ phoneNumber).build();
+ }
+ }
+ }
+ return conversationInfo;
+ }
+
+ /** Reads and writes {@link ConversationInfo} on disk. */
+ static class ConversationInfosProtoDiskReadWriter extends
+ AbstractProtoDiskReadWriter<List<ConversationInfo>> {
+
+ private final String mConversationInfoFileName;
+
+ ConversationInfosProtoDiskReadWriter(@NonNull File baseDir,
+ @NonNull String conversationInfoFileName,
+ long writeDelayMs, @NonNull ScheduledExecutorService scheduledExecutorService) {
+ super(baseDir, writeDelayMs, scheduledExecutorService);
+ mConversationInfoFileName = conversationInfoFileName;
+ }
+
+ @Override
+ ProtoStreamWriter<List<ConversationInfo>> protoStreamWriter() {
+ return (protoOutputStream, data) -> {
+ for (ConversationInfo conversationInfo : data) {
+ long token = protoOutputStream.start(ConversationInfosProto.CONVERSATION_INFOS);
+ conversationInfo.writeToProto(protoOutputStream);
+ protoOutputStream.end(token);
+ }
+ };
+ }
+
+ @Override
+ ProtoStreamReader<List<ConversationInfo>> protoStreamReader() {
+ return protoInputStream -> {
+ List<ConversationInfo> results = Lists.newArrayList();
+ try {
+ while (protoInputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
+ if (protoInputStream.getFieldNumber()
+ != (int) ConversationInfosProto.CONVERSATION_INFOS) {
+ continue;
+ }
+ long token = protoInputStream.start(
+ ConversationInfosProto.CONVERSATION_INFOS);
+ ConversationInfo conversationInfo = ConversationInfo.readFromProto(
+ protoInputStream);
+ protoInputStream.end(token);
+ results.add(conversationInfo);
+ }
+ } catch (IOException e) {
+ Slog.e(TAG, "Failed to read protobuf input stream.", e);
+ }
+ return results;
+ };
+ }
+
+ /**
+ * Schedules a flush of the specified conversations to disk.
+ */
+ @MainThread
+ void scheduleConversationsSave(@NonNull List<ConversationInfo> conversationInfos) {
+ scheduleSave(mConversationInfoFileName, conversationInfos);
+ }
+
+ /**
+ * Saves the specified conversations immediately. This should be used when device is
+ * powering off.
+ */
+ @MainThread
+ void saveConversationsImmediately(@NonNull List<ConversationInfo> conversationInfos) {
+ saveImmediately(mConversationInfoFileName, conversationInfos);
+ }
+ }
}
diff --git a/services/people/java/com/android/server/people/data/DataManager.java b/services/people/java/com/android/server/people/data/DataManager.java
index 7a3ed53..c8673f8 100644
--- a/services/people/java/com/android/server/people/data/DataManager.java
+++ b/services/people/java/com/android/server/people/data/DataManager.java
@@ -86,6 +86,7 @@
private final Context mContext;
private final Injector mInjector;
private final ScheduledExecutorService mUsageStatsQueryExecutor;
+ private final ScheduledExecutorService mDiskReadWriterExecutor;
private final SparseArray<UserData> mUserDataArray = new SparseArray<>();
private final SparseArray<BroadcastReceiver> mBroadcastReceivers = new SparseArray<>();
@@ -113,6 +114,7 @@
BackgroundThread.getHandler());
mMmsSmsContentObserver = new MmsSmsContentObserver(
BackgroundThread.getHandler());
+ mDiskReadWriterExecutor = mInjector.createScheduledExecutor();
}
/** Initialization. Called when the system services are up running. */
@@ -122,13 +124,18 @@
mUserManager = mContext.getSystemService(UserManager.class);
mShortcutServiceInternal.addListener(new ShortcutServiceListener());
+
+ IntentFilter shutdownIntentFilter = new IntentFilter(Intent.ACTION_SHUTDOWN);
+ BroadcastReceiver shutdownBroadcastReceiver = new ShutdownBroadcastReceiver();
+ mContext.registerReceiver(shutdownBroadcastReceiver, shutdownIntentFilter);
}
/** This method is called when a user is unlocked. */
public void onUserUnlocked(int userId) {
UserData userData = mUserDataArray.get(userId);
if (userData == null) {
- userData = new UserData(userId);
+ userData = new UserData(userId, mDiskReadWriterExecutor,
+ mInjector.createContactsQueryHelper(mContext));
mUserDataArray.put(userId, userData);
}
userData.setUserUnlocked();
@@ -662,6 +669,14 @@
}
}
+ private class ShutdownBroadcastReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ forAllPackages(PackageData::saveToDisk);
+ }
+ }
+
@VisibleForTesting
static class Injector {
diff --git a/services/people/java/com/android/server/people/data/PackageData.java b/services/people/java/com/android/server/people/data/PackageData.java
index 75b870c..f67699c 100644
--- a/services/people/java/com/android/server/people/data/PackageData.java
+++ b/services/people/java/com/android/server/people/data/PackageData.java
@@ -22,6 +22,8 @@
import android.content.LocusId;
import android.text.TextUtils;
+import java.io.File;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -43,17 +45,36 @@
private final Predicate<String> mIsDefaultSmsAppPredicate;
+ private final File mPackageDataDir;
+
PackageData(@NonNull String packageName, @UserIdInt int userId,
@NonNull Predicate<String> isDefaultDialerPredicate,
- @NonNull Predicate<String> isDefaultSmsAppPredicate) {
+ @NonNull Predicate<String> isDefaultSmsAppPredicate,
+ @NonNull ScheduledExecutorService scheduledExecutorService,
+ @NonNull File perUserPeopleDataDir,
+ @NonNull ContactsQueryHelper helper) {
mPackageName = packageName;
mUserId = userId;
- mConversationStore = new ConversationStore();
+
+ mPackageDataDir = new File(perUserPeopleDataDir, mPackageName);
+ mConversationStore = new ConversationStore(mPackageDataDir, scheduledExecutorService,
+ helper);
mEventStore = new EventStore();
mIsDefaultDialerPredicate = isDefaultDialerPredicate;
mIsDefaultSmsAppPredicate = isDefaultSmsAppPredicate;
}
+ /** Called when user is unlocked. */
+ void loadFromDisk() {
+ mPackageDataDir.mkdirs();
+ mConversationStore.loadConversationsFromDisk();
+ }
+
+ /** Called when device is shutting down. */
+ void saveToDisk() {
+ mConversationStore.saveConversationsToDisk();
+ }
+
@NonNull
public String getPackageName() {
return mPackageName;
diff --git a/services/people/java/com/android/server/people/data/UserData.java b/services/people/java/com/android/server/people/data/UserData.java
index 4e8fd16..aaa5db8 100644
--- a/services/people/java/com/android/server/people/data/UserData.java
+++ b/services/people/java/com/android/server/people/data/UserData.java
@@ -19,10 +19,13 @@
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UserIdInt;
+import android.os.Environment;
import android.text.TextUtils;
import android.util.ArrayMap;
+import java.io.File;
import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
/** The data associated with a user profile. */
@@ -30,6 +33,12 @@
private final @UserIdInt int mUserId;
+ private final File mPerUserPeopleDataDir;
+
+ private final ScheduledExecutorService mScheduledExecutorService;
+
+ private final ContactsQueryHelper mHelper;
+
private boolean mIsUnlocked;
private Map<String, PackageData> mPackageDataMap = new ArrayMap<>();
@@ -40,8 +49,12 @@
@Nullable
private String mDefaultSmsApp;
- UserData(@UserIdInt int userId) {
+ UserData(@UserIdInt int userId, @NonNull ScheduledExecutorService scheduledExecutorService,
+ ContactsQueryHelper helper) {
mUserId = userId;
+ mPerUserPeopleDataDir = new File(Environment.getDataSystemCeDirectory(mUserId), "people");
+ mScheduledExecutorService = scheduledExecutorService;
+ mHelper = helper;
}
@UserIdInt int getUserId() {
@@ -56,6 +69,13 @@
void setUserUnlocked() {
mIsUnlocked = true;
+
+ // Ensures per user root directory for people data is present, and attempt to load
+ // data from disk.
+ mPerUserPeopleDataDir.mkdirs();
+ for (PackageData packageData : mPackageDataMap.values()) {
+ packageData.loadFromDisk();
+ }
}
void setUserStopped() {
@@ -103,7 +123,8 @@
}
private PackageData createPackageData(String packageName) {
- return new PackageData(packageName, mUserId, this::isDefaultDialer, this::isDefaultSmsApp);
+ return new PackageData(packageName, mUserId, this::isDefaultDialer, this::isDefaultSmsApp,
+ mScheduledExecutorService, mPerUserPeopleDataDir, mHelper);
}
private boolean isDefaultDialer(String packageName) {
diff --git a/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java b/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java
index 331ad59..03b5e38 100644
--- a/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java
+++ b/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java
@@ -21,16 +21,24 @@
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
+import android.annotation.Nullable;
+import android.content.Context;
import android.content.LocusId;
import android.content.pm.ShortcutInfo;
import android.net.Uri;
+import android.os.FileUtils;
+import android.text.format.DateUtils;
import android.util.ArraySet;
+import androidx.test.InstrumentationRegistry;
+
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
+import java.io.File;
import java.util.Set;
@RunWith(JUnit4.class)
@@ -42,11 +50,34 @@
private static final Uri CONTACT_URI = Uri.parse("tel:+1234567890");
private static final String PHONE_NUMBER = "+1234567890";
+ private static final String SHORTCUT_ID_2 = "ghi";
+ private static final String NOTIFICATION_CHANNEL_ID_2 = "test : ghi";
+ private static final LocusId LOCUS_ID_2 = new LocusId("jkl");
+ private static final Uri CONTACT_URI_2 = Uri.parse("tel:+3234567890");
+ private static final String PHONE_NUMBER_2 = "+3234567890";
+
+ private static final String SHORTCUT_ID_3 = "mno";
+ private static final String NOTIFICATION_CHANNEL_ID_3 = "test : mno";
+ private static final LocusId LOCUS_ID_3 = new LocusId("pqr");
+ private static final Uri CONTACT_URI_3 = Uri.parse("tel:+9234567890");
+ private static final String PHONE_NUMBER_3 = "+9234567890";
+
+ private MockScheduledExecutorService mMockScheduledExecutorService;
+ private TestContactQueryHelper mTestContactQueryHelper;
private ConversationStore mConversationStore;
+ private File mFile;
@Before
public void setUp() {
- mConversationStore = new ConversationStore();
+ Context ctx = InstrumentationRegistry.getContext();
+ mFile = new File(ctx.getCacheDir(), "testdir");
+ mTestContactQueryHelper = new TestContactQueryHelper(ctx);
+ resetConversationStore();
+ }
+
+ @After
+ public void tearDown() {
+ FileUtils.deleteContentsAndDir(mFile);
}
@Test
@@ -153,6 +184,130 @@
mConversationStore.getConversationByNotificationChannelId(NOTIFICATION_CHANNEL_ID));
}
+ @Test
+ public void testDataPersistenceAndRestoration() {
+ // Add conversation infos, causing it to be loaded to disk.
+ ConversationInfo in1 = buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI,
+ PHONE_NUMBER, NOTIFICATION_CHANNEL_ID);
+ ConversationInfo in2 = buildConversationInfo(SHORTCUT_ID_2, LOCUS_ID_2, CONTACT_URI_2,
+ PHONE_NUMBER_2, NOTIFICATION_CHANNEL_ID_2);
+ ConversationInfo in3 = buildConversationInfo(SHORTCUT_ID_3, LOCUS_ID_3, CONTACT_URI_3,
+ PHONE_NUMBER_3, NOTIFICATION_CHANNEL_ID_3);
+ mConversationStore.addOrUpdate(in1);
+ mConversationStore.addOrUpdate(in2);
+ mConversationStore.addOrUpdate(in3);
+
+ long futuresExecuted = mMockScheduledExecutorService.fastForwardTime(
+ 3L * DateUtils.MINUTE_IN_MILLIS);
+ assertEquals(1, futuresExecuted);
+
+ mMockScheduledExecutorService.resetTimeElapsedMillis();
+
+ // During restoration, we want to confirm that this conversation was removed.
+ mConversationStore.deleteConversation(SHORTCUT_ID_3);
+ mMockScheduledExecutorService.fastForwardTime(3L * DateUtils.MINUTE_IN_MILLIS);
+
+ mTestContactQueryHelper.setQueryResult(true, true);
+ mTestContactQueryHelper.setPhoneNumberResult(PHONE_NUMBER, PHONE_NUMBER_2);
+
+ resetConversationStore();
+ ConversationInfo out1 = mConversationStore.getConversation(SHORTCUT_ID);
+ ConversationInfo out2 = mConversationStore.getConversation(SHORTCUT_ID_2);
+ ConversationInfo out3 = mConversationStore.getConversation(SHORTCUT_ID_3);
+ mConversationStore.deleteConversation(SHORTCUT_ID);
+ mConversationStore.deleteConversation(SHORTCUT_ID_2);
+ mConversationStore.deleteConversation(SHORTCUT_ID_3);
+ assertEquals(in1, out1);
+ assertEquals(in2, out2);
+ assertNull(out3);
+ }
+
+ @Test
+ public void testDelayedDiskWrites() {
+ ConversationInfo in1 = buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI,
+ PHONE_NUMBER, NOTIFICATION_CHANNEL_ID);
+ ConversationInfo in2 = buildConversationInfo(SHORTCUT_ID_2, LOCUS_ID_2, CONTACT_URI_2,
+ PHONE_NUMBER_2, NOTIFICATION_CHANNEL_ID_2);
+ ConversationInfo in3 = buildConversationInfo(SHORTCUT_ID_3, LOCUS_ID_3, CONTACT_URI_3,
+ PHONE_NUMBER_3, NOTIFICATION_CHANNEL_ID_3);
+
+ mConversationStore.addOrUpdate(in1);
+ mMockScheduledExecutorService.fastForwardTime(3L * DateUtils.MINUTE_IN_MILLIS);
+ mMockScheduledExecutorService.resetTimeElapsedMillis();
+
+ // Should not see second conversation on disk because of disk write delay has not been
+ // reached.
+ mConversationStore.addOrUpdate(in2);
+ mMockScheduledExecutorService.fastForwardTime(DateUtils.MINUTE_IN_MILLIS);
+
+ mTestContactQueryHelper.setQueryResult(true);
+ mTestContactQueryHelper.setPhoneNumberResult(PHONE_NUMBER);
+
+ resetConversationStore();
+ ConversationInfo out1 = mConversationStore.getConversation(SHORTCUT_ID);
+ ConversationInfo out2 = mConversationStore.getConversation(SHORTCUT_ID_2);
+ assertEquals(in1, out1);
+ assertNull(out2);
+
+ mConversationStore.addOrUpdate(in2);
+ mMockScheduledExecutorService.fastForwardTime(3L * DateUtils.MINUTE_IN_MILLIS);
+ mMockScheduledExecutorService.resetTimeElapsedMillis();
+
+ mConversationStore.addOrUpdate(in3);
+ mMockScheduledExecutorService.fastForwardTime(3L * DateUtils.MINUTE_IN_MILLIS);
+
+ mTestContactQueryHelper.reset();
+ mTestContactQueryHelper.setQueryResult(true, true, true);
+ mTestContactQueryHelper.setPhoneNumberResult(PHONE_NUMBER, PHONE_NUMBER_2, PHONE_NUMBER_3);
+
+ resetConversationStore();
+ out1 = mConversationStore.getConversation(SHORTCUT_ID);
+ out2 = mConversationStore.getConversation(SHORTCUT_ID_2);
+ ConversationInfo out3 = mConversationStore.getConversation(SHORTCUT_ID_3);
+ assertEquals(in1, out1);
+ assertEquals(in2, out2);
+ assertEquals(in3, out3);
+ }
+
+ @Test
+ public void testMimicDevicePowerOff() {
+
+ // Even without fast forwarding time with our mock ScheduledExecutorService, we should
+ // see the conversations immediately saved to disk.
+ ConversationInfo in1 = buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI,
+ PHONE_NUMBER, NOTIFICATION_CHANNEL_ID);
+ ConversationInfo in2 = buildConversationInfo(SHORTCUT_ID_2, LOCUS_ID_2, CONTACT_URI_2,
+ PHONE_NUMBER_2, NOTIFICATION_CHANNEL_ID_2);
+
+ mConversationStore.addOrUpdate(in1);
+ mConversationStore.addOrUpdate(in2);
+ mConversationStore.saveConversationsToDisk();
+
+ // Ensure that futures were cancelled and the immediate flush occurred.
+ assertEquals(0, mMockScheduledExecutorService.getFutures().size());
+
+ // Expect to see 2 executes: loadConversationFromDisk and saveConversationsToDisk.
+ // loadConversationFromDisk gets called each time we call #resetConversationStore().
+ assertEquals(2, mMockScheduledExecutorService.getExecutes().size());
+
+ mTestContactQueryHelper.setQueryResult(true, true);
+ mTestContactQueryHelper.setPhoneNumberResult(PHONE_NUMBER, PHONE_NUMBER_2);
+
+ resetConversationStore();
+ ConversationInfo out1 = mConversationStore.getConversation(SHORTCUT_ID);
+ ConversationInfo out2 = mConversationStore.getConversation(SHORTCUT_ID_2);
+ assertEquals(in1, out1);
+ assertEquals(in2, out2);
+ }
+
+ private void resetConversationStore() {
+ mFile.mkdir();
+ mMockScheduledExecutorService = new MockScheduledExecutorService();
+ mConversationStore = new ConversationStore(mFile, mMockScheduledExecutorService,
+ mTestContactQueryHelper);
+ mConversationStore.loadConversationsFromDisk();
+ }
+
private static ConversationInfo buildConversationInfo(String shortcutId) {
return buildConversationInfo(shortcutId, null, null, null, null);
}
@@ -171,4 +326,54 @@
.setBubbled(true)
.build();
}
+
+ private static class TestContactQueryHelper extends ContactsQueryHelper {
+
+ private int mQueryCalls;
+ private boolean[] mQueryResult;
+
+ private int mPhoneNumberCalls;
+ private String[] mPhoneNumberResult;
+
+ TestContactQueryHelper(Context context) {
+ super(context);
+
+ mQueryCalls = 0;
+ mPhoneNumberCalls = 0;
+ }
+
+ private void setQueryResult(boolean... values) {
+ mQueryResult = values;
+ }
+
+ private void setPhoneNumberResult(String... values) {
+ mPhoneNumberResult = values;
+ }
+
+ private void reset() {
+ mQueryCalls = 0;
+ mQueryResult = null;
+ mPhoneNumberCalls = 0;
+ mPhoneNumberResult = null;
+ }
+
+ @Override
+ boolean query(String contactUri) {
+ if (mQueryResult != null && mQueryCalls < mQueryResult.length) {
+ return mQueryResult[mQueryCalls++];
+ }
+ mQueryCalls++;
+ return false;
+ }
+
+ @Override
+ @Nullable
+ String getPhoneNumber() {
+ if (mPhoneNumberResult != null && mPhoneNumberCalls < mPhoneNumberResult.length) {
+ return mPhoneNumberResult[mPhoneNumberCalls++];
+ }
+ mPhoneNumberCalls++;
+ return null;
+ }
+ }
}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/MockScheduledExecutorService.java b/services/tests/servicestests/src/com/android/server/people/data/MockScheduledExecutorService.java
new file mode 100644
index 0000000..8b8ba12
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/MockScheduledExecutorService.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2020 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.people.data;
+
+import com.android.internal.util.Preconditions;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Mock implementation of ScheduledExecutorService for testing. All commands will run
+ * synchronously. Commands passed to {@link #submit(Runnable)} and {@link #execute(Runnable)} will
+ * run immediately. Commands scheduled via {@link #schedule(Runnable, long, TimeUnit)} will run
+ * after calling {@link #fastForwardTime(long)}.
+ */
+class MockScheduledExecutorService implements ScheduledExecutorService {
+
+ private final List<Runnable> mExecutes = new ArrayList<>();
+ private final List<MockScheduledFuture<?>> mFutures = new ArrayList<>();
+ private long mTimeElapsedMillis = 0;
+
+ /**
+ * Advances fake time, runs all the commands for which the delay has expired.
+ */
+ long fastForwardTime(long millis) {
+ mTimeElapsedMillis += millis;
+ ImmutableList<MockScheduledFuture<?>> futuresCopy = ImmutableList.copyOf(mFutures);
+ mFutures.clear();
+ long totalExecuted = 0;
+ for (MockScheduledFuture<?> future : futuresCopy) {
+ if (future.getDelay() < mTimeElapsedMillis) {
+ future.getCommand().run();
+ mExecutes.add(future.getCommand());
+ totalExecuted += 1;
+ } else {
+ mFutures.add(future);
+ }
+ }
+ return totalExecuted;
+ }
+
+ List<Runnable> getExecutes() {
+ return mExecutes;
+ }
+
+ List<MockScheduledFuture<?>> getFutures() {
+ return mFutures;
+ }
+
+ void resetTimeElapsedMillis() {
+ mTimeElapsedMillis = 0;
+ }
+
+ /**
+ * Fakes a schedule execution of {@link Runnable}. The command will be executed by an explicit
+ * call to {@link #fastForwardTime(long)}.
+ */
+ @Override
+ public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+ Preconditions.checkState(unit == TimeUnit.MILLISECONDS);
+ MockScheduledFuture<?> future = new MockScheduledFuture<>(command, delay, unit);
+ mFutures.add(future);
+ return future;
+ }
+
+ @Override
+ public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period,
+ TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay,
+ long delay, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void shutdown() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List<Runnable> shutdownNow() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return false;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> Future<T> submit(Callable<T> task) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> Future<T> submit(Runnable task, T result) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Future<?> submit(Runnable command) {
+ mExecutes.add(command);
+ MockScheduledFuture<?> future = new MockScheduledFuture<>(command, 0,
+ TimeUnit.MILLISECONDS);
+ future.getCommand().run();
+ return future;
+ }
+
+ @Override
+ public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+ throws InterruptedException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout,
+ TimeUnit unit) throws InterruptedException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+ throws ExecutionException, InterruptedException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+ throws ExecutionException, InterruptedException, TimeoutException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ mExecutes.add(command);
+ command.run();
+ }
+
+ class MockScheduledFuture<V> implements ScheduledFuture<V> {
+
+ private final Runnable mCommand;
+ private final long mDelay;
+ private boolean mCancelled = false;
+
+ MockScheduledFuture(Runnable command, long delay, TimeUnit timeUnit) {
+ mCommand = command;
+ mDelay = delay;
+ }
+
+ public long getDelay() {
+ return mDelay;
+ }
+
+ public Runnable getCommand() {
+ return mCommand;
+ }
+
+ @Override
+ public long getDelay(TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int compareTo(Delayed o) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ mCancelled = true;
+ return mFutures.remove(this);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return mCancelled;
+ }
+
+ @Override
+ public boolean isDone() {
+ return !mFutures.contains(this);
+ }
+
+ @Override
+ public V get() throws ExecutionException, InterruptedException {
+ return null;
+ }
+
+ @Override
+ public V get(long timeout, TimeUnit unit)
+ throws ExecutionException, InterruptedException, TimeoutException {
+ return null;
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java b/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java
index ec4789a..1ddc21e 100644
--- a/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java
+++ b/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java
@@ -20,15 +20,19 @@
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
+import android.content.Context;
import android.content.LocusId;
import android.content.pm.ShortcutInfo;
import android.net.Uri;
+import androidx.test.InstrumentationRegistry;
+
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
+import java.io.File;
import java.util.List;
@RunWith(JUnit4.class)
@@ -52,8 +56,12 @@
@Before
public void setUp() {
+ Context ctx = InstrumentationRegistry.getContext();
+ File testDir = new File(ctx.getCacheDir(), "testdir");
+ testDir.mkdir();
mPackageData = new PackageData(
- PACKAGE_NAME, USER_ID, pkg -> mIsDefaultDialer, pkg -> mIsDefaultSmsApp);
+ PACKAGE_NAME, USER_ID, pkg -> mIsDefaultDialer, pkg -> mIsDefaultSmsApp,
+ new MockScheduledExecutorService(), testDir, new ContactsQueryHelper(ctx));
ConversationInfo conversationInfo = new ConversationInfo.Builder()
.setShortcutId(SHORTCUT_ID)
.setLocusId(LOCUS_ID)
diff --git a/services/tests/servicestests/src/com/android/server/people/data/UsageStatsQueryHelperTest.java b/services/tests/servicestests/src/com/android/server/people/data/UsageStatsQueryHelperTest.java
index e4248a0..b273578 100644
--- a/services/tests/servicestests/src/com/android/server/people/data/UsageStatsQueryHelperTest.java
+++ b/services/tests/servicestests/src/com/android/server/people/data/UsageStatsQueryHelperTest.java
@@ -29,8 +29,11 @@
import android.annotation.UserIdInt;
import android.app.usage.UsageEvents;
import android.app.usage.UsageStatsManagerInternal;
+import android.content.Context;
import android.content.LocusId;
+import androidx.test.InstrumentationRegistry;
+
import com.android.server.LocalServices;
import org.junit.After;
@@ -41,10 +44,12 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Predicate;
@RunWith(JUnit4.class)
@@ -69,7 +74,13 @@
addLocalServiceMock(UsageStatsManagerInternal.class, mUsageStatsManagerInternal);
- mPackageData = new TestPackageData(PKG_NAME, USER_ID_PRIMARY, pkg -> false, pkg -> false);
+ Context ctx = InstrumentationRegistry.getContext();
+ File testDir = new File(ctx.getCacheDir(), "testdir");
+ ScheduledExecutorService scheduledExecutorService = new MockScheduledExecutorService();
+ ContactsQueryHelper helper = new ContactsQueryHelper(ctx);
+
+ mPackageData = new TestPackageData(PKG_NAME, USER_ID_PRIMARY, pkg -> false, pkg -> false,
+ scheduledExecutorService, testDir, helper);
mPackageData.mConversationStore.mConversationInfo = new ConversationInfo.Builder()
.setShortcutId(SHORTCUT_ID)
.setNotificationChannelId(NOTIFICATION_CHANNEL_ID)
@@ -175,7 +186,7 @@
assertEquals(createInAppConversationEvent(130_000L, 30), events.get(2));
}
- private void addUsageEvents(UsageEvents.Event ... events) {
+ private void addUsageEvents(UsageEvents.Event... events) {
UsageEvents usageEvents = new UsageEvents(Arrays.asList(events), new String[]{});
when(mUsageStatsManagerInternal.queryEventsForUser(anyInt(), anyLong(), anyLong(),
anyBoolean(), anyBoolean())).thenReturn(usageEvents);
@@ -228,6 +239,12 @@
private ConversationInfo mConversationInfo;
+ TestConversationStore(File packageDir,
+ ScheduledExecutorService scheduledExecutorService,
+ ContactsQueryHelper helper) {
+ super(packageDir, scheduledExecutorService, helper);
+ }
+
@Override
@Nullable
ConversationInfo getConversation(@Nullable String shortcutId) {
@@ -237,13 +254,18 @@
private static class TestPackageData extends PackageData {
- private final TestConversationStore mConversationStore = new TestConversationStore();
+ private final TestConversationStore mConversationStore;
private final TestEventStore mEventStore = new TestEventStore();
TestPackageData(@NonNull String packageName, @UserIdInt int userId,
@NonNull Predicate<String> isDefaultDialerPredicate,
- @NonNull Predicate<String> isDefaultSmsAppPredicate) {
- super(packageName, userId, isDefaultDialerPredicate, isDefaultSmsAppPredicate);
+ @NonNull Predicate<String> isDefaultSmsAppPredicate,
+ @NonNull ScheduledExecutorService scheduledExecutorService, @NonNull File rootDir,
+ @NonNull ContactsQueryHelper helper) {
+ super(packageName, userId, isDefaultDialerPredicate, isDefaultSmsAppPredicate,
+ scheduledExecutorService, rootDir, helper);
+ mConversationStore = new TestConversationStore(rootDir, scheduledExecutorService,
+ helper);
}
@Override