1 /*
2  * Copyright (C) 2022 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.safetycenter.persistence;
18 
19 import static android.os.Build.VERSION_CODES.TIRAMISU;
20 
21 import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
22 import static org.xmlpull.v1.XmlPullParser.END_TAG;
23 import static org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES;
24 import static org.xmlpull.v1.XmlPullParser.START_DOCUMENT;
25 import static org.xmlpull.v1.XmlPullParser.START_TAG;
26 import static org.xmlpull.v1.XmlPullParser.TEXT;
27 
28 import static java.util.Collections.unmodifiableList;
29 
30 import android.util.AtomicFile;
31 import android.util.Log;
32 import android.util.Xml;
33 
34 import androidx.annotation.RequiresApi;
35 
36 import org.xmlpull.v1.XmlPullParser;
37 import org.xmlpull.v1.XmlPullParserException;
38 import org.xmlpull.v1.XmlSerializer;
39 
40 import java.io.File;
41 import java.io.FileInputStream;
42 import java.io.FileNotFoundException;
43 import java.io.FileOutputStream;
44 import java.io.IOException;
45 import java.nio.charset.StandardCharsets;
46 import java.time.DateTimeException;
47 import java.time.Instant;
48 import java.util.ArrayList;
49 import java.util.List;
50 
51 /** Utility class to persist identifiers and metadata of safety source issues related to a user. */
52 @RequiresApi(TIRAMISU)
53 public final class SafetyCenterIssuesPersistence {
54 
55     private static final String TAG = "SafetyCenterIssuesPersi";
56 
57     private static final String TAG_ISSUES = "issues";
58     private static final String TAG_ISSUE = "issue";
59 
60     private static final String ATTRIBUTE_VERSION = "version";
61     private static final String ATTRIBUTE_KEY = "key";
62     private static final String ATTRIBUTE_FIRST_SEEN_AT = "first_seen_at_epoch_millis";
63     private static final String ATTRIBUTE_DISMISSED_AT = "dismissed_at_epoch_millis";
64     private static final String ATTRIBUTE_DISMISS_COUNT = "dismiss_count";
65     private static final String ATTRIBUTE_NOTIFICATION_DISMISSED_AT =
66             "notification_dismissed_at_epoch_millis";
67 
68     private static final int NO_VERSION = -1;
69     private static final int CURRENT_VERSION = 2;
70     private static final int MIN_COMPATIBLE_VERSION = 0;
71 
SafetyCenterIssuesPersistence()72     private SafetyCenterIssuesPersistence() {}
73 
74     /**
75      * Read the issues state from persistence.
76      *
77      * <p>This will perform I/O operations synchronously.
78      *
79      * @param file the file to read from
80      * @return the list of issue states read or an empty list if the file does not exist
81      * @throws PersistenceException if there is an unexpected error while reading the file
82      */
read(File file)83     public static List<PersistedSafetyCenterIssue> read(File file) throws PersistenceException {
84         XmlPullParser parser = Xml.newPullParser();
85         try (FileInputStream inputStream = new AtomicFile(file).openRead()) {
86             parser.setFeature(FEATURE_PROCESS_NAMESPACES, true);
87             parser.setInput(inputStream, null);
88             return unmodifiableList(parseXml(parser));
89         } catch (FileNotFoundException e) {
90             Log.i(TAG, "File not found: " + file);
91             return unmodifiableList(new ArrayList<>());
92         } catch (IOException | XmlPullParserException e) {
93             throw new PersistenceException("Failed to read file: " + file, e);
94         }
95     }
96 
parseXml(XmlPullParser parser)97     private static List<PersistedSafetyCenterIssue> parseXml(XmlPullParser parser)
98             throws IOException, PersistenceException, XmlPullParserException {
99         if (parser.getEventType() != START_DOCUMENT) {
100             throw new PersistenceException("Unexpected parser state");
101         }
102         parser.nextTag();
103         validateElementStart(parser, TAG_ISSUES);
104         List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues = parseIssues(parser);
105         while (parser.getEventType() == TEXT && parser.isWhitespace()) {
106             parser.next();
107         }
108         if (parser.getEventType() != END_DOCUMENT) {
109             throw new PersistenceException("Unexpected extra root element");
110         }
111         return persistedSafetyCenterIssues;
112     }
113 
parseIssues(XmlPullParser parser)114     private static List<PersistedSafetyCenterIssue> parseIssues(XmlPullParser parser)
115             throws IOException, PersistenceException, XmlPullParserException {
116         int version = NO_VERSION;
117         for (int i = 0; i < parser.getAttributeCount(); i++) {
118             switch (parser.getAttributeName(i)) {
119                 case ATTRIBUTE_VERSION:
120                     version = parseInteger(parser.getAttributeValue(i), parser.getAttributeName(i));
121                     break;
122                 default:
123                     throw attributeUnexpected(parser.getAttributeName(i));
124             }
125         }
126         if (version == NO_VERSION) {
127             throw new PersistenceException("Missing version");
128         }
129         if (version > CURRENT_VERSION || version < MIN_COMPATIBLE_VERSION) {
130             throw new PersistenceException("Unsupported version: " + version);
131         }
132 
133         List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues = new ArrayList<>();
134         parser.nextTag();
135         while (parser.getEventType() == START_TAG && parser.getName().equals(TAG_ISSUE)) {
136             persistedSafetyCenterIssues.add(parseIssue(parser));
137         }
138         validateElementEnd(parser, TAG_ISSUES);
139         parser.next();
140         return persistedSafetyCenterIssues;
141     }
142 
parseIssue(XmlPullParser parser)143     private static PersistedSafetyCenterIssue parseIssue(XmlPullParser parser)
144             throws IOException, PersistenceException, XmlPullParserException {
145         boolean hasDismissedAt = false;
146         boolean hasDismissCount = false;
147         PersistedSafetyCenterIssue.Builder builder = new PersistedSafetyCenterIssue.Builder();
148         for (int i = 0; i < parser.getAttributeCount(); i++) {
149             switch (parser.getAttributeName(i)) {
150                 case ATTRIBUTE_KEY:
151                     builder.setKey(parser.getAttributeValue(i));
152                     break;
153                 case ATTRIBUTE_FIRST_SEEN_AT:
154                     builder.setFirstSeenAt(
155                             parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i)));
156                     break;
157                 case ATTRIBUTE_DISMISSED_AT:
158                     hasDismissedAt = true;
159                     builder.setDismissedAt(
160                             parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i)));
161                     break;
162                 case ATTRIBUTE_DISMISS_COUNT:
163                     hasDismissCount = true;
164                     try {
165                         builder.setDismissCount(
166                                 parseInteger(
167                                         parser.getAttributeValue(i), parser.getAttributeName(i)));
168                     } catch (IllegalArgumentException e) {
169                         throw attributeInvalid(
170                                 parser.getAttributeValue(i), parser.getAttributeName(i), e);
171                     }
172                     break;
173                 case ATTRIBUTE_NOTIFICATION_DISMISSED_AT:
174                     builder.setNotificationDismissedAt(
175                             parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i)));
176                     break;
177                 default:
178                     throw attributeUnexpected(parser.getAttributeName(i));
179             }
180         }
181         if (hasDismissedAt && !hasDismissCount) {
182             builder.setDismissCount(1);
183         }
184         parser.nextTag();
185         validateElementEnd(parser, TAG_ISSUE);
186         parser.nextTag();
187         try {
188             return builder.build();
189         } catch (IllegalStateException e) {
190             throw new PersistenceException("Element issue invalid", e);
191         }
192     }
193 
validateElementStart(XmlPullParser parser, String name)194     private static void validateElementStart(XmlPullParser parser, String name)
195             throws PersistenceException, XmlPullParserException {
196         if (parser.getEventType() != START_TAG || !parser.getName().equals(name)) {
197             throw new PersistenceException(String.format("Element %s missing", name));
198         }
199     }
200 
validateElementEnd(XmlPullParser parser, String name)201     private static void validateElementEnd(XmlPullParser parser, String name)
202             throws PersistenceException, XmlPullParserException {
203         if (parser.getEventType() != END_TAG || !parser.getName().equals(name)) {
204             throw new PersistenceException(String.format("Element %s not closed", name));
205         }
206     }
207 
parseInteger(String value, String name)208     private static int parseInteger(String value, String name) throws PersistenceException {
209         try {
210             return Integer.parseInt(value);
211         } catch (NumberFormatException e) {
212             throw attributeInvalid(value, name, e);
213         }
214     }
215 
parseInstant(String value, String name)216     private static Instant parseInstant(String value, String name) throws PersistenceException {
217         try {
218             return Instant.ofEpochMilli(Long.parseLong(value));
219         } catch (DateTimeException | NumberFormatException e) {
220             throw attributeInvalid(value, name, e);
221         }
222     }
223 
attributeUnexpected(String name)224     private static PersistenceException attributeUnexpected(String name) {
225         return new PersistenceException("Unexpected attribute " + name);
226     }
227 
attributeInvalid(String value, String name, Throwable ex)228     private static PersistenceException attributeInvalid(String value, String name, Throwable ex) {
229         return new PersistenceException(
230                 "Attribute value \"" + value + "\" for " + name + " invalid", ex);
231     }
232 
233     /**
234      * Write the issues state to persistence.
235      *
236      * <p>This will perform I/O operations synchronously.
237      *
238      * @param persistedSafetyCenterIssues the issue states to write
239      * @param file the file to write to
240      */
write( List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues, File file)241     public static void write(
242             List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues, File file) {
243         AtomicFile atomicFile = new AtomicFile(file);
244         FileOutputStream outputStream = null;
245         try {
246             outputStream = atomicFile.startWrite();
247 
248             XmlSerializer serializer = Xml.newSerializer();
249             serializer.setOutput(outputStream, StandardCharsets.UTF_8.name());
250             serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
251             serializer.startDocument(null, true);
252 
253             serializeIssues(serializer, persistedSafetyCenterIssues);
254 
255             serializer.endDocument();
256             atomicFile.finishWrite(outputStream);
257         } catch (Exception e) {
258             Log.wtf(TAG, "Failed to write, restoring backup: " + file, e);
259             atomicFile.failWrite(outputStream);
260         } finally {
261             try {
262                 outputStream.close();
263             } catch (Exception ignored) {
264                 // Ignored.
265             }
266         }
267     }
268 
serializeIssues( XmlSerializer serializer, List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues)269     private static void serializeIssues(
270             XmlSerializer serializer, List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues)
271             throws IOException {
272         serializer.startTag(null, TAG_ISSUES);
273         serializer.attribute(null, ATTRIBUTE_VERSION, Integer.toString(CURRENT_VERSION));
274 
275         for (int i = 0; i < persistedSafetyCenterIssues.size(); i++) {
276             PersistedSafetyCenterIssue persistedSafetyCenterIssue =
277                     persistedSafetyCenterIssues.get(i);
278 
279             serializer.startTag(null, TAG_ISSUE);
280             serializer.attribute(null, ATTRIBUTE_KEY, persistedSafetyCenterIssue.getKey());
281             serializer.attribute(
282                     null,
283                     ATTRIBUTE_FIRST_SEEN_AT,
284                     Long.toString(persistedSafetyCenterIssue.getFirstSeenAt().toEpochMilli()));
285             Instant dismissedAt = persistedSafetyCenterIssue.getDismissedAt();
286             if (dismissedAt != null) {
287                 serializer.attribute(
288                         null, ATTRIBUTE_DISMISSED_AT, Long.toString(dismissedAt.toEpochMilli()));
289             }
290             int dismissCount = persistedSafetyCenterIssue.getDismissCount();
291             if (dismissCount > 0) {
292                 serializer.attribute(null, ATTRIBUTE_DISMISS_COUNT, Integer.toString(dismissCount));
293             }
294             Instant notificationDismissedAt =
295                     persistedSafetyCenterIssue.getNotificationDismissedAt();
296             if (notificationDismissedAt != null) {
297                 serializer.attribute(
298                         null,
299                         ATTRIBUTE_NOTIFICATION_DISMISSED_AT,
300                         Long.toString(notificationDismissedAt.toEpochMilli()));
301             }
302             serializer.endTag(null, TAG_ISSUE);
303         }
304 
305         serializer.endTag(null, TAG_ISSUES);
306     }
307 }
308