In ShortcutService, save data of each package in a separate file

This CL splits the main Xml file into individual files for each package.
This enables us to gain disk I/O performance improvements by selectively
updating only the parts that have been changed (coming in future CLs).
The old implementation was re-writing the entire shortcut data of a user
on every change.

Bug: 153680823
Test: atest com.android.server.pm.ShortcutManagerTest1
            com.android.server.pm.ShortcutManagerTest2
            com.android.server.pm.ShortcutManagerTest3
            com.android.server.pm.ShortcutManagerTest4
            com.android.server.pm.ShortcutManagerTest5
            com.android.server.pm.ShortcutManagerTest6
            com.android.server.pm.ShortcutManagerTest7
            com.android.server.pm.ShortcutManagerTest8
            com.android.server.pm.ShortcutManagerTest9
            com.android.server.pm.ShortcutManagerTest10
            com.android.server.pm.ShortcutManagerTest11
Change-Id: I3a29b80ecfa353eddb1bb778eb845d863f057e0a
diff --git a/services/core/java/com/android/server/pm/ShortcutLauncher.java b/services/core/java/com/android/server/pm/ShortcutLauncher.java
index 0fecb631..0ebe5961 100644
--- a/services/core/java/com/android/server/pm/ShortcutLauncher.java
+++ b/services/core/java/com/android/server/pm/ShortcutLauncher.java
@@ -22,20 +22,29 @@
 import android.content.pm.ShortcutInfo;
 import android.util.ArrayMap;
 import android.util.ArraySet;
+import android.util.AtomicFile;
 import android.util.Slog;
+import android.util.Xml;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.pm.ShortcutService.DumpFilter;
 import com.android.server.pm.ShortcutUser.PackageWithUser;
 
+import libcore.io.IoUtils;
+
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 import org.xmlpull.v1.XmlSerializer;
 
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -254,6 +263,53 @@
         out.endTag(null, TAG_ROOT);
     }
 
+    public static ShortcutLauncher loadFromFile(File path, ShortcutUser shortcutUser,
+            int ownerUserId, boolean fromBackup) {
+
+        final AtomicFile file = new AtomicFile(path);
+        final FileInputStream in;
+        try {
+            in = file.openRead();
+        } catch (FileNotFoundException e) {
+            if (ShortcutService.DEBUG) {
+                Slog.d(TAG, "Not found " + path);
+            }
+            return null;
+        }
+
+        try {
+            final BufferedInputStream bis = new BufferedInputStream(in);
+
+            ShortcutLauncher ret = null;
+            XmlPullParser parser = Xml.newPullParser();
+            parser.setInput(bis, StandardCharsets.UTF_8.name());
+
+            int type;
+            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
+                if (type != XmlPullParser.START_TAG) {
+                    continue;
+                }
+                final int depth = parser.getDepth();
+
+                final String tag = parser.getName();
+                if (ShortcutService.DEBUG_LOAD) {
+                    Slog.d(TAG, String.format("depth=%d type=%d name=%s", depth, type, tag));
+                }
+                if ((depth == 1) && TAG_ROOT.equals(tag)) {
+                    ret = loadFromXml(parser, shortcutUser, ownerUserId, fromBackup);
+                    continue;
+                }
+                ShortcutService.throwForInvalidTag(depth, tag);
+            }
+            return ret;
+        } catch (IOException | XmlPullParserException e) {
+            Slog.e(TAG, "Failed to read file " + file.getBaseFile(), e);
+            return null;
+        } finally {
+            IoUtils.closeQuietly(in);
+        }
+    }
+
     /**
      * Load.
      */
diff --git a/services/core/java/com/android/server/pm/ShortcutPackage.java b/services/core/java/com/android/server/pm/ShortcutPackage.java
index 71a4bb4..0cc77c1 100644
--- a/services/core/java/com/android/server/pm/ShortcutPackage.java
+++ b/services/core/java/com/android/server/pm/ShortcutPackage.java
@@ -31,8 +31,10 @@
 import android.text.format.Formatter;
 import android.util.ArrayMap;
 import android.util.ArraySet;
+import android.util.AtomicFile;
 import android.util.Log;
 import android.util.Slog;
+import android.util.Xml;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.ArrayUtils;
@@ -43,15 +45,21 @@
 import com.android.server.pm.ShortcutService.ShortcutOperation;
 import com.android.server.pm.ShortcutService.Stats;
 
+import libcore.io.IoUtils;
+
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 import org.xmlpull.v1.XmlSerializer;
 
+import java.io.BufferedInputStream;
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -1720,6 +1728,53 @@
         out.endTag(null, TAG_SHORTCUT);
     }
 
+    public static ShortcutPackage loadFromFile(ShortcutService s, ShortcutUser shortcutUser,
+            File path, boolean fromBackup) {
+
+        final AtomicFile file = new AtomicFile(path);
+        final FileInputStream in;
+        try {
+            in = file.openRead();
+        } catch (FileNotFoundException e) {
+            if (ShortcutService.DEBUG) {
+                Slog.d(TAG, "Not found " + path);
+            }
+            return null;
+        }
+
+        try {
+            final BufferedInputStream bis = new BufferedInputStream(in);
+
+            ShortcutPackage ret = null;
+            XmlPullParser parser = Xml.newPullParser();
+            parser.setInput(bis, StandardCharsets.UTF_8.name());
+
+            int type;
+            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
+                if (type != XmlPullParser.START_TAG) {
+                    continue;
+                }
+                final int depth = parser.getDepth();
+
+                final String tag = parser.getName();
+                if (ShortcutService.DEBUG_LOAD) {
+                    Slog.d(TAG, String.format("depth=%d type=%d name=%s", depth, type, tag));
+                }
+                if ((depth == 1) && TAG_ROOT.equals(tag)) {
+                    ret = loadFromXml(s, shortcutUser, parser, fromBackup);
+                    continue;
+                }
+                ShortcutService.throwForInvalidTag(depth, tag);
+            }
+            return ret;
+        } catch (IOException | XmlPullParserException e) {
+            Slog.e(TAG, "Failed to read file " + file.getBaseFile(), e);
+            return null;
+        } finally {
+            IoUtils.closeQuietly(in);
+        }
+    }
+
     public static ShortcutPackage loadFromXml(ShortcutService s, ShortcutUser shortcutUser,
             XmlPullParser parser, boolean fromBackup)
             throws IOException, XmlPullParserException {
diff --git a/services/core/java/com/android/server/pm/ShortcutPackageItem.java b/services/core/java/com/android/server/pm/ShortcutPackageItem.java
index 6d9d69e..801c6cb 100644
--- a/services/core/java/com/android/server/pm/ShortcutPackageItem.java
+++ b/services/core/java/com/android/server/pm/ShortcutPackageItem.java
@@ -18,8 +18,10 @@
 import android.annotation.NonNull;
 import android.content.pm.PackageInfo;
 import android.content.pm.ShortcutInfo;
+import android.util.AtomicFile;
 import android.util.Slog;
 
+import com.android.internal.util.FastXmlSerializer;
 import com.android.internal.util.Preconditions;
 
 import org.json.JSONException;
@@ -27,7 +29,11 @@
 import org.xmlpull.v1.XmlPullParserException;
 import org.xmlpull.v1.XmlSerializer;
 
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Objects;
 
 /**
@@ -143,6 +149,31 @@
     public abstract void saveToXml(@NonNull XmlSerializer out, boolean forBackup)
             throws IOException, XmlPullParserException;
 
+    public void saveToFile(File path, boolean forBackup) {
+        final AtomicFile file = new AtomicFile(path);
+        FileOutputStream os = null;
+        try {
+            os = file.startWrite();
+            final BufferedOutputStream bos = new BufferedOutputStream(os);
+
+            // Write to XML
+            XmlSerializer itemOut = new FastXmlSerializer();
+            itemOut.setOutput(bos, StandardCharsets.UTF_8.name());
+            itemOut.startDocument(null, true);
+
+            saveToXml(itemOut, forBackup);
+
+            itemOut.endDocument();
+
+            bos.flush();
+            os.flush();
+            file.finishWrite(os);
+        } catch (XmlPullParserException | IOException e) {
+            Slog.e(TAG, "Failed to write to file " + file.getBaseFile(), e);
+            file.failWrite(os);
+        }
+    }
+
     public JSONObject dumpCheckin(boolean clear) throws JSONException {
         final JSONObject result = new JSONObject();
         result.put(KEY_NAME, mPackageName);
diff --git a/services/core/java/com/android/server/pm/ShortcutUser.java b/services/core/java/com/android/server/pm/ShortcutUser.java
index eab3f4d..df6d321 100644
--- a/services/core/java/com/android/server/pm/ShortcutUser.java
+++ b/services/core/java/com/android/server/pm/ShortcutUser.java
@@ -21,6 +21,7 @@
 import android.content.ComponentName;
 import android.content.pm.ShortcutManager;
 import android.metrics.LogMaker;
+import android.os.FileUtils;
 import android.text.TextUtils;
 import android.text.format.Formatter;
 import android.util.ArrayMap;
@@ -30,7 +31,6 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.internal.util.Preconditions;
 import com.android.server.pm.ShortcutService.DumpFilter;
 import com.android.server.pm.ShortcutService.InvalidFileFormatException;
 
@@ -55,6 +55,9 @@
 class ShortcutUser {
     private static final String TAG = ShortcutService.TAG;
 
+    static final String DIRECTORY_PACKAGES = "packages";
+    static final String DIRECTORY_LUANCHERS = "launchers";
+
     static final String TAG_ROOT = "user";
     private static final String TAG_LAUNCHER = "launcher";
 
@@ -354,6 +357,13 @@
                     mService.injectBuildFingerprint());
         }
 
+        if (!forBackup) {
+            // Since we are not handling package deletion yet, or any single package changes, just
+            // clean the directory and rewrite all the ShortcutPackageItems.
+            final File root = mService.injectUserDataPath(mUserId);
+            FileUtils.deleteContents(new File(root, DIRECTORY_PACKAGES));
+            FileUtils.deleteContents(new File(root, DIRECTORY_LUANCHERS));
+        }
         // Can't use forEachPackageItem due to the checked exceptions.
         {
             final int size = mLaunchers.size();
@@ -371,20 +381,47 @@
         out.endTag(null, TAG_ROOT);
     }
 
-    private void saveShortcutPackageItem(XmlSerializer out,
-            ShortcutPackageItem spi, boolean forBackup) throws IOException, XmlPullParserException {
+    private void saveShortcutPackageItem(XmlSerializer out, ShortcutPackageItem spi,
+            boolean forBackup) throws IOException, XmlPullParserException {
         if (forBackup) {
             if (spi.getPackageUserId() != spi.getOwnerUserId()) {
                 return; // Don't save cross-user information.
             }
+            spi.saveToXml(out, forBackup);
+        } else {
+            // Save each ShortcutPackageItem in a separate Xml file.
+            final File path = getShortcutPackageItemFile(spi);
+            if (ShortcutService.DEBUG) {
+                Slog.d(TAG, "Saving package item " + spi.getPackageName() + " to " + path);
+            }
+
+            path.getParentFile().mkdirs();
+            spi.saveToFile(path, forBackup);
         }
-        spi.saveToXml(out, forBackup);
+    }
+
+    private File getShortcutPackageItemFile(ShortcutPackageItem spi) {
+        boolean isShortcutLauncher = spi instanceof ShortcutLauncher;
+
+        final File path = new File(mService.injectUserDataPath(mUserId),
+                isShortcutLauncher ? DIRECTORY_LUANCHERS : DIRECTORY_PACKAGES);
+
+        final String fileName;
+        if (isShortcutLauncher) {
+            // Package user id and owner id can have different values for ShortcutLaunchers. Adding
+            // user Id to the file name to create a unique path. Owner id is used in the root path.
+            fileName = spi.getPackageName() + spi.getPackageUserId() + ".xml";
+        } else {
+            fileName = spi.getPackageName() + ".xml";
+        }
+
+        return new File(path, fileName);
     }
 
     public static ShortcutUser loadFromXml(ShortcutService s, XmlPullParser parser, int userId,
             boolean fromBackup) throws IOException, XmlPullParserException, InvalidFileFormatException {
         final ShortcutUser ret = new ShortcutUser(s, userId);
-
+        boolean readShortcutItems = false;
         try {
             ret.mKnownLocales = ShortcutService.parseStringAttribute(parser,
                     ATTR_KNOWN_LOCALES);
@@ -422,12 +459,14 @@
 
                             // Don't use addShortcut(), we don't need to save the icon.
                             ret.mPackages.put(shortcuts.getPackageName(), shortcuts);
+                            readShortcutItems = true;
                             continue;
                         }
 
                         case ShortcutLauncher.TAG_ROOT: {
                             ret.addLauncher(
                                     ShortcutLauncher.loadFromXml(parser, ret, userId, fromBackup));
+                            readShortcutItems = true;
                             continue;
                         }
                     }
@@ -438,9 +477,44 @@
             throw new ShortcutService.InvalidFileFormatException(
                     "Unable to parse file", e);
         }
+
+        if (readShortcutItems) {
+            // If the shortcuts info was read from the main Xml, skip reading from individual files.
+            // Data will get stored in the new format during the next call to saveToXml().
+            // TODO: ret.forAllPackageItems((ShortcutPackageItem item) -> item.markDirty());
+            s.scheduleSaveUser(userId);
+        } else {
+            final File root = s.injectUserDataPath(userId);
+
+            forAllFilesIn(new File(root, DIRECTORY_PACKAGES), (File f) -> {
+                final ShortcutPackage sp = ShortcutPackage.loadFromFile(s, ret, f, fromBackup);
+                if (sp != null) {
+                    ret.mPackages.put(sp.getPackageName(), sp);
+                }
+            });
+
+            forAllFilesIn(new File(root, DIRECTORY_LUANCHERS), (File f) -> {
+                final ShortcutLauncher sl =
+                        ShortcutLauncher.loadFromFile(f, ret, userId, fromBackup);
+                if (sl != null) {
+                    ret.addLauncher(sl);
+                }
+            });
+        }
+
         return ret;
     }
 
+    private static void forAllFilesIn(File path, Consumer<File> callback) {
+        if (!path.exists()) {
+            return;
+        }
+        File[] list = path.listFiles();
+        for (File f : list) {
+            callback.accept(f);
+        }
+    }
+
     public ComponentName getLastKnownLauncher() {
         return mLastKnownLauncher;
     }