/* * Copyright (C) 2022 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.safetycenter.persistence; import static android.os.Build.VERSION_CODES.TIRAMISU; import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT; import static org.xmlpull.v1.XmlPullParser.END_TAG; import static org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES; import static org.xmlpull.v1.XmlPullParser.START_DOCUMENT; import static org.xmlpull.v1.XmlPullParser.START_TAG; import static org.xmlpull.v1.XmlPullParser.TEXT; import static java.util.Collections.unmodifiableList; import android.util.AtomicFile; import android.util.Log; import android.util.Xml; import androidx.annotation.RequiresApi; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlSerializer; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.DateTimeException; import java.time.Instant; import java.util.ArrayList; import java.util.List; /** Utility class to persist identifiers and metadata of safety source issues related to a user. */ @RequiresApi(TIRAMISU) public final class SafetyCenterIssuesPersistence { private static final String TAG = "SafetyCenterIssuesPersi"; private static final String TAG_ISSUES = "issues"; private static final String TAG_ISSUE = "issue"; private static final String ATTRIBUTE_VERSION = "version"; private static final String ATTRIBUTE_KEY = "key"; private static final String ATTRIBUTE_FIRST_SEEN_AT = "first_seen_at_epoch_millis"; private static final String ATTRIBUTE_DISMISSED_AT = "dismissed_at_epoch_millis"; private static final String ATTRIBUTE_DISMISS_COUNT = "dismiss_count"; private static final String ATTRIBUTE_NOTIFICATION_DISMISSED_AT = "notification_dismissed_at_epoch_millis"; private static final int NO_VERSION = -1; private static final int CURRENT_VERSION = 2; private static final int MIN_COMPATIBLE_VERSION = 0; private SafetyCenterIssuesPersistence() {} /** * Read the issues state from persistence. * *

This will perform I/O operations synchronously. * * @param file the file to read from * @return the list of issue states read or an empty list if the file does not exist * @throws PersistenceException if there is an unexpected error while reading the file */ public static List read(File file) throws PersistenceException { XmlPullParser parser = Xml.newPullParser(); try (FileInputStream inputStream = new AtomicFile(file).openRead()) { parser.setFeature(FEATURE_PROCESS_NAMESPACES, true); parser.setInput(inputStream, null); return unmodifiableList(parseXml(parser)); } catch (FileNotFoundException e) { Log.i(TAG, "File not found: " + file); return unmodifiableList(new ArrayList<>()); } catch (IOException | XmlPullParserException e) { throw new PersistenceException("Failed to read file: " + file, e); } } private static List parseXml(XmlPullParser parser) throws IOException, PersistenceException, XmlPullParserException { if (parser.getEventType() != START_DOCUMENT) { throw new PersistenceException("Unexpected parser state"); } parser.nextTag(); validateElementStart(parser, TAG_ISSUES); List persistedSafetyCenterIssues = parseIssues(parser); while (parser.getEventType() == TEXT && parser.isWhitespace()) { parser.next(); } if (parser.getEventType() != END_DOCUMENT) { throw new PersistenceException("Unexpected extra root element"); } return persistedSafetyCenterIssues; } private static List parseIssues(XmlPullParser parser) throws IOException, PersistenceException, XmlPullParserException { int version = NO_VERSION; for (int i = 0; i < parser.getAttributeCount(); i++) { switch (parser.getAttributeName(i)) { case ATTRIBUTE_VERSION: version = parseInteger(parser.getAttributeValue(i), parser.getAttributeName(i)); break; default: throw attributeUnexpected(parser.getAttributeName(i)); } } if (version == NO_VERSION) { throw new PersistenceException("Missing version"); } if (version > CURRENT_VERSION || version < MIN_COMPATIBLE_VERSION) { throw new PersistenceException("Unsupported version: " + version); } List persistedSafetyCenterIssues = new ArrayList<>(); parser.nextTag(); while (parser.getEventType() == START_TAG && parser.getName().equals(TAG_ISSUE)) { persistedSafetyCenterIssues.add(parseIssue(parser)); } validateElementEnd(parser, TAG_ISSUES); parser.next(); return persistedSafetyCenterIssues; } private static PersistedSafetyCenterIssue parseIssue(XmlPullParser parser) throws IOException, PersistenceException, XmlPullParserException { boolean hasDismissedAt = false; boolean hasDismissCount = false; PersistedSafetyCenterIssue.Builder builder = new PersistedSafetyCenterIssue.Builder(); for (int i = 0; i < parser.getAttributeCount(); i++) { switch (parser.getAttributeName(i)) { case ATTRIBUTE_KEY: builder.setKey(parser.getAttributeValue(i)); break; case ATTRIBUTE_FIRST_SEEN_AT: builder.setFirstSeenAt( parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i))); break; case ATTRIBUTE_DISMISSED_AT: hasDismissedAt = true; builder.setDismissedAt( parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i))); break; case ATTRIBUTE_DISMISS_COUNT: hasDismissCount = true; try { builder.setDismissCount( parseInteger( parser.getAttributeValue(i), parser.getAttributeName(i))); } catch (IllegalArgumentException e) { throw attributeInvalid( parser.getAttributeValue(i), parser.getAttributeName(i), e); } break; case ATTRIBUTE_NOTIFICATION_DISMISSED_AT: builder.setNotificationDismissedAt( parseInstant(parser.getAttributeValue(i), parser.getAttributeName(i))); break; default: throw attributeUnexpected(parser.getAttributeName(i)); } } if (hasDismissedAt && !hasDismissCount) { builder.setDismissCount(1); } parser.nextTag(); validateElementEnd(parser, TAG_ISSUE); parser.nextTag(); try { return builder.build(); } catch (IllegalStateException e) { throw new PersistenceException("Element issue invalid", e); } } private static void validateElementStart(XmlPullParser parser, String name) throws PersistenceException, XmlPullParserException { if (parser.getEventType() != START_TAG || !parser.getName().equals(name)) { throw new PersistenceException(String.format("Element %s missing", name)); } } private static void validateElementEnd(XmlPullParser parser, String name) throws PersistenceException, XmlPullParserException { if (parser.getEventType() != END_TAG || !parser.getName().equals(name)) { throw new PersistenceException(String.format("Element %s not closed", name)); } } private static int parseInteger(String value, String name) throws PersistenceException { try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw attributeInvalid(value, name, e); } } private static Instant parseInstant(String value, String name) throws PersistenceException { try { return Instant.ofEpochMilli(Long.parseLong(value)); } catch (DateTimeException | NumberFormatException e) { throw attributeInvalid(value, name, e); } } private static PersistenceException attributeUnexpected(String name) { return new PersistenceException("Unexpected attribute " + name); } private static PersistenceException attributeInvalid(String value, String name, Throwable ex) { return new PersistenceException( "Attribute value \"" + value + "\" for " + name + " invalid", ex); } /** * Write the issues state to persistence. * *

This will perform I/O operations synchronously. * * @param persistedSafetyCenterIssues the issue states to write * @param file the file to write to */ public static void write( List persistedSafetyCenterIssues, File file) { AtomicFile atomicFile = new AtomicFile(file); FileOutputStream outputStream = null; try { outputStream = atomicFile.startWrite(); XmlSerializer serializer = Xml.newSerializer(); serializer.setOutput(outputStream, StandardCharsets.UTF_8.name()); serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true); serializer.startDocument(null, true); serializeIssues(serializer, persistedSafetyCenterIssues); serializer.endDocument(); atomicFile.finishWrite(outputStream); } catch (Exception e) { Log.wtf(TAG, "Failed to write, restoring backup: " + file, e); atomicFile.failWrite(outputStream); } finally { try { outputStream.close(); } catch (Exception ignored) { // Ignored. } } } private static void serializeIssues( XmlSerializer serializer, List persistedSafetyCenterIssues) throws IOException { serializer.startTag(null, TAG_ISSUES); serializer.attribute(null, ATTRIBUTE_VERSION, Integer.toString(CURRENT_VERSION)); for (int i = 0; i < persistedSafetyCenterIssues.size(); i++) { PersistedSafetyCenterIssue persistedSafetyCenterIssue = persistedSafetyCenterIssues.get(i); serializer.startTag(null, TAG_ISSUE); serializer.attribute(null, ATTRIBUTE_KEY, persistedSafetyCenterIssue.getKey()); serializer.attribute( null, ATTRIBUTE_FIRST_SEEN_AT, Long.toString(persistedSafetyCenterIssue.getFirstSeenAt().toEpochMilli())); Instant dismissedAt = persistedSafetyCenterIssue.getDismissedAt(); if (dismissedAt != null) { serializer.attribute( null, ATTRIBUTE_DISMISSED_AT, Long.toString(dismissedAt.toEpochMilli())); } int dismissCount = persistedSafetyCenterIssue.getDismissCount(); if (dismissCount > 0) { serializer.attribute(null, ATTRIBUTE_DISMISS_COUNT, Integer.toString(dismissCount)); } Instant notificationDismissedAt = persistedSafetyCenterIssue.getNotificationDismissedAt(); if (notificationDismissedAt != null) { serializer.attribute( null, ATTRIBUTE_NOTIFICATION_DISMISSED_AT, Long.toString(notificationDismissedAt.toEpochMilli())); } serializer.endTag(null, TAG_ISSUE); } serializer.endTag(null, TAG_ISSUES); } }