1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.people.data;
18 
19 import android.annotation.MainThread;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.annotation.WorkerThread;
23 import android.content.LocusId;
24 import android.net.Uri;
25 import android.util.ArrayMap;
26 import android.util.Slog;
27 import android.util.proto.ProtoInputStream;
28 
29 import com.android.internal.annotations.GuardedBy;
30 import com.android.server.people.ConversationInfosProto;
31 
32 import com.google.android.collect.Lists;
33 
34 import java.io.ByteArrayInputStream;
35 import java.io.ByteArrayOutputStream;
36 import java.io.DataInputStream;
37 import java.io.DataOutputStream;
38 import java.io.File;
39 import java.io.IOException;
40 import java.util.ArrayList;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.concurrent.ScheduledExecutorService;
44 import java.util.function.Consumer;
45 
46 /**
47  * The store that stores and accesses the conversations data for a package.
48  */
49 class ConversationStore {
50 
51     private static final String TAG = ConversationStore.class.getSimpleName();
52 
53     private static final String CONVERSATIONS_FILE_NAME = "conversations";
54 
55     private static final int CONVERSATION_INFOS_END_TOKEN = -1;
56 
57     // Shortcut ID -> Conversation Info
58     @GuardedBy("this")
59     private final Map<String, ConversationInfo> mConversationInfoMap = new ArrayMap<>();
60 
61     // Locus ID -> Shortcut ID
62     @GuardedBy("this")
63     private final Map<LocusId, String> mLocusIdToShortcutIdMap = new ArrayMap<>();
64 
65     // Contact URI -> Shortcut ID
66     @GuardedBy("this")
67     private final Map<Uri, String> mContactUriToShortcutIdMap = new ArrayMap<>();
68 
69     // Phone Number -> Shortcut ID
70     @GuardedBy("this")
71     private final Map<String, String> mPhoneNumberToShortcutIdMap = new ArrayMap<>();
72 
73     // Notification Channel ID -> Shortcut ID
74     @GuardedBy("this")
75     private final Map<String, String> mNotifChannelIdToShortcutIdMap = new ArrayMap<>();
76 
77     private final ScheduledExecutorService mScheduledExecutorService;
78     private final File mPackageDir;
79 
80     private ConversationInfosProtoDiskReadWriter mConversationInfosProtoDiskReadWriter;
81 
ConversationStore(@onNull File packageDir, @NonNull ScheduledExecutorService scheduledExecutorService)82     ConversationStore(@NonNull File packageDir,
83             @NonNull ScheduledExecutorService scheduledExecutorService) {
84         mScheduledExecutorService = scheduledExecutorService;
85         mPackageDir = packageDir;
86     }
87 
88     /**
89      * Loads conversations from disk to memory in a background thread. This should be called
90      * after the device powers on and the user has been unlocked.
91      */
92     @WorkerThread
loadConversationsFromDisk()93     void loadConversationsFromDisk() {
94         ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
95                 getConversationInfosProtoDiskReadWriter();
96         if (conversationInfosProtoDiskReadWriter == null) {
97             return;
98         }
99         List<ConversationInfo> conversationsOnDisk =
100                 conversationInfosProtoDiskReadWriter.read(CONVERSATIONS_FILE_NAME);
101         if (conversationsOnDisk == null) {
102             return;
103         }
104         for (ConversationInfo conversationInfo : conversationsOnDisk) {
105             updateConversationsInMemory(conversationInfo);
106         }
107     }
108 
109     /**
110      * Immediately flushes current conversations to disk. This should be called when device is
111      * powering off.
112      */
113     @MainThread
saveConversationsToDisk()114     void saveConversationsToDisk() {
115         ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
116                 getConversationInfosProtoDiskReadWriter();
117         if (conversationInfosProtoDiskReadWriter != null) {
118             List<ConversationInfo> conversations;
119             synchronized (this) {
120                 conversations = new ArrayList<>(mConversationInfoMap.values());
121             }
122             conversationInfosProtoDiskReadWriter.saveConversationsImmediately(conversations);
123         }
124     }
125 
126     @MainThread
addOrUpdate(@onNull ConversationInfo conversationInfo)127     void addOrUpdate(@NonNull ConversationInfo conversationInfo) {
128         updateConversationsInMemory(conversationInfo);
129         scheduleUpdateConversationsOnDisk();
130     }
131 
132     @MainThread
133     @Nullable
deleteConversation(@onNull String shortcutId)134     ConversationInfo deleteConversation(@NonNull String shortcutId) {
135         ConversationInfo conversationInfo;
136         synchronized (this) {
137             conversationInfo = mConversationInfoMap.remove(shortcutId);
138             if (conversationInfo == null) {
139                 return null;
140             }
141 
142             LocusId locusId = conversationInfo.getLocusId();
143             if (locusId != null) {
144                 mLocusIdToShortcutIdMap.remove(locusId);
145             }
146 
147             Uri contactUri = conversationInfo.getContactUri();
148             if (contactUri != null) {
149                 mContactUriToShortcutIdMap.remove(contactUri);
150             }
151 
152             String phoneNumber = conversationInfo.getContactPhoneNumber();
153             if (phoneNumber != null) {
154                 mPhoneNumberToShortcutIdMap.remove(phoneNumber);
155             }
156 
157             String notifChannelId = conversationInfo.getNotificationChannelId();
158             if (notifChannelId != null) {
159                 mNotifChannelIdToShortcutIdMap.remove(notifChannelId);
160             }
161         }
162         scheduleUpdateConversationsOnDisk();
163         return conversationInfo;
164     }
165 
forAllConversations(@onNull Consumer<ConversationInfo> consumer)166     void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
167         List<ConversationInfo> conversations;
168         synchronized (this) {
169             conversations = new ArrayList<>(mConversationInfoMap.values());
170         }
171         for (ConversationInfo ci : conversations) {
172             consumer.accept(ci);
173         }
174     }
175 
176     @Nullable
getConversation(@ullable String shortcutId)177     synchronized ConversationInfo getConversation(@Nullable String shortcutId) {
178         return shortcutId != null ? mConversationInfoMap.get(shortcutId) : null;
179     }
180 
181     @Nullable
getConversationByLocusId(@onNull LocusId locusId)182     synchronized ConversationInfo getConversationByLocusId(@NonNull LocusId locusId) {
183         return getConversation(mLocusIdToShortcutIdMap.get(locusId));
184     }
185 
186     @Nullable
getConversationByContactUri(@onNull Uri contactUri)187     synchronized ConversationInfo getConversationByContactUri(@NonNull Uri contactUri) {
188         return getConversation(mContactUriToShortcutIdMap.get(contactUri));
189     }
190 
191     @Nullable
getConversationByPhoneNumber(@onNull String phoneNumber)192     synchronized ConversationInfo getConversationByPhoneNumber(@NonNull String phoneNumber) {
193         return getConversation(mPhoneNumberToShortcutIdMap.get(phoneNumber));
194     }
195 
196     @Nullable
getConversationByNotificationChannelId( @onNull String notifChannelId)197     synchronized ConversationInfo getConversationByNotificationChannelId(
198             @NonNull String notifChannelId) {
199         return getConversation(mNotifChannelIdToShortcutIdMap.get(notifChannelId));
200     }
201 
onDestroy()202     void onDestroy() {
203         synchronized (this) {
204             mConversationInfoMap.clear();
205             mContactUriToShortcutIdMap.clear();
206             mLocusIdToShortcutIdMap.clear();
207             mNotifChannelIdToShortcutIdMap.clear();
208             mPhoneNumberToShortcutIdMap.clear();
209         }
210         ConversationInfosProtoDiskReadWriter writer = getConversationInfosProtoDiskReadWriter();
211         if (writer != null) {
212             writer.deleteConversationsFile();
213         }
214     }
215 
216     @Nullable
getBackupPayload()217     byte[] getBackupPayload() {
218         ByteArrayOutputStream baos = new ByteArrayOutputStream();
219         DataOutputStream conversationInfosOut = new DataOutputStream(baos);
220         forAllConversations(conversationInfo -> {
221             byte[] backupPayload = conversationInfo.getBackupPayload();
222             if (backupPayload == null) {
223                 return;
224             }
225             try {
226                 conversationInfosOut.writeInt(backupPayload.length);
227                 conversationInfosOut.write(backupPayload);
228             } catch (IOException e) {
229                 Slog.e(TAG, "Failed to write conversation info to backup payload.", e);
230             }
231         });
232         try {
233             conversationInfosOut.writeInt(CONVERSATION_INFOS_END_TOKEN);
234         } catch (IOException e) {
235             Slog.e(TAG, "Failed to write conversation infos end token to backup payload.", e);
236             return null;
237         }
238         return baos.toByteArray();
239     }
240 
restore(@onNull byte[] payload)241     void restore(@NonNull byte[] payload) {
242         DataInputStream in = new DataInputStream(new ByteArrayInputStream(payload));
243         try {
244             for (int conversationInfoSize = in.readInt();
245                     conversationInfoSize != CONVERSATION_INFOS_END_TOKEN;
246                     conversationInfoSize = in.readInt()) {
247                 byte[] conversationInfoPayload = new byte[conversationInfoSize];
248                 in.readFully(conversationInfoPayload, 0, conversationInfoSize);
249                 ConversationInfo conversationInfo = ConversationInfo.readFromBackupPayload(
250                         conversationInfoPayload);
251                 if (conversationInfo != null) {
252                     addOrUpdate(conversationInfo);
253                 }
254             }
255         } catch (IOException e) {
256             Slog.e(TAG, "Failed to read conversation info from payload.", e);
257         }
258     }
259 
updateConversationsInMemory( @onNull ConversationInfo conversationInfo)260     private synchronized void updateConversationsInMemory(
261             @NonNull ConversationInfo conversationInfo) {
262         mConversationInfoMap.put(conversationInfo.getShortcutId(), conversationInfo);
263 
264         LocusId locusId = conversationInfo.getLocusId();
265         if (locusId != null) {
266             mLocusIdToShortcutIdMap.put(locusId, conversationInfo.getShortcutId());
267         }
268 
269         Uri contactUri = conversationInfo.getContactUri();
270         if (contactUri != null) {
271             mContactUriToShortcutIdMap.put(contactUri, conversationInfo.getShortcutId());
272         }
273 
274         String phoneNumber = conversationInfo.getContactPhoneNumber();
275         if (phoneNumber != null) {
276             mPhoneNumberToShortcutIdMap.put(phoneNumber, conversationInfo.getShortcutId());
277         }
278 
279         String notifChannelId = conversationInfo.getNotificationChannelId();
280         if (notifChannelId != null) {
281             mNotifChannelIdToShortcutIdMap.put(notifChannelId, conversationInfo.getShortcutId());
282         }
283     }
284 
285     /** Schedules a dump of all conversations onto disk, overwriting existing values. */
286     @MainThread
scheduleUpdateConversationsOnDisk()287     private void scheduleUpdateConversationsOnDisk() {
288         ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
289                 getConversationInfosProtoDiskReadWriter();
290         if (conversationInfosProtoDiskReadWriter != null) {
291             List<ConversationInfo> conversations;
292             synchronized (this) {
293                 conversations = new ArrayList<>(mConversationInfoMap.values());
294             }
295             conversationInfosProtoDiskReadWriter.scheduleConversationsSave(conversations);
296         }
297     }
298 
299     @Nullable
getConversationInfosProtoDiskReadWriter()300     private ConversationInfosProtoDiskReadWriter getConversationInfosProtoDiskReadWriter() {
301         if (!mPackageDir.exists()) {
302             Slog.e(TAG, "Package data directory does not exist: " + mPackageDir.getAbsolutePath());
303             return null;
304         }
305         if (mConversationInfosProtoDiskReadWriter == null) {
306             mConversationInfosProtoDiskReadWriter = new ConversationInfosProtoDiskReadWriter(
307                     mPackageDir, CONVERSATIONS_FILE_NAME, mScheduledExecutorService);
308         }
309         return mConversationInfosProtoDiskReadWriter;
310     }
311 
312     /** Reads and writes {@link ConversationInfo}s on disk. */
313     private static class ConversationInfosProtoDiskReadWriter extends
314             AbstractProtoDiskReadWriter<List<ConversationInfo>> {
315 
316         private final String mConversationInfoFileName;
317 
ConversationInfosProtoDiskReadWriter(@onNull File rootDir, @NonNull String conversationInfoFileName, @NonNull ScheduledExecutorService scheduledExecutorService)318         ConversationInfosProtoDiskReadWriter(@NonNull File rootDir,
319                 @NonNull String conversationInfoFileName,
320                 @NonNull ScheduledExecutorService scheduledExecutorService) {
321             super(rootDir, scheduledExecutorService);
322             mConversationInfoFileName = conversationInfoFileName;
323         }
324 
325         @Override
protoStreamWriter()326         ProtoStreamWriter<List<ConversationInfo>> protoStreamWriter() {
327             return (protoOutputStream, data) -> {
328                 for (ConversationInfo conversationInfo : data) {
329                     long token = protoOutputStream.start(ConversationInfosProto.CONVERSATION_INFOS);
330                     conversationInfo.writeToProto(protoOutputStream);
331                     protoOutputStream.end(token);
332                 }
333             };
334         }
335 
336         @Override
protoStreamReader()337         ProtoStreamReader<List<ConversationInfo>> protoStreamReader() {
338             return protoInputStream -> {
339                 List<ConversationInfo> results = Lists.newArrayList();
340                 try {
341                     while (protoInputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
342                         if (protoInputStream.getFieldNumber()
343                                 != (int) ConversationInfosProto.CONVERSATION_INFOS) {
344                             continue;
345                         }
346                         long token = protoInputStream.start(
347                                 ConversationInfosProto.CONVERSATION_INFOS);
348                         ConversationInfo conversationInfo = ConversationInfo.readFromProto(
349                                 protoInputStream);
350                         protoInputStream.end(token);
351                         results.add(conversationInfo);
352                     }
353                 } catch (IOException e) {
354                     Slog.e(TAG, "Failed to read protobuf input stream.", e);
355                 }
356                 return results;
357             };
358         }
359 
360         /**
361          * Schedules a flush of the specified conversations to disk.
362          */
363         @MainThread
364         void scheduleConversationsSave(@NonNull List<ConversationInfo> conversationInfos) {
365             scheduleSave(mConversationInfoFileName, conversationInfos);
366         }
367 
368         /**
369          * Saves the specified conversations immediately. This should be used when device is
370          * powering off.
371          */
372         @MainThread
373         void saveConversationsImmediately(@NonNull List<ConversationInfo> conversationInfos) {
374             saveImmediately(mConversationInfoFileName, conversationInfos);
375         }
376 
377         @WorkerThread
378         void deleteConversationsFile() {
379             delete(mConversationInfoFileName);
380         }
381     }
382 }
383