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