Add helper class to read/write updatable font dir.

Bug: 173619554
Test: atest FrameworksCoreTests:TypefaceSystemFallbackTest
Test: atest FrameworksServicesTests:UpdatableFontDirTest
Change-Id: I950ed71a14d536eaeba5ab754a6c56a637b97217
diff --git a/core/tests/coretests/src/android/graphics/TypefaceSystemFallbackTest.java b/core/tests/coretests/src/android/graphics/TypefaceSystemFallbackTest.java
index 05ff218..465ea17 100644
--- a/core/tests/coretests/src/android/graphics/TypefaceSystemFallbackTest.java
+++ b/core/tests/coretests/src/android/graphics/TypefaceSystemFallbackTest.java
@@ -48,7 +48,9 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
 import java.util.Locale;
+import java.util.Map;
 
 @SmallTest
 @RunWith(AndroidJUnit4.class)
@@ -145,9 +147,13 @@
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
+        Map<String, File> updatableFontMap = new HashMap<>();
+        for (File file : new File(TEST_UPDATABLE_FONT_DIR).listFiles()) {
+            updatableFontMap.put(file.getName(), file);
+        }
 
         final FontConfig.Alias[] aliases = SystemFonts.buildSystemFallback(TEST_FONTS_XML,
-                TEST_FONT_DIR, TEST_UPDATABLE_FONT_DIR, oemCustomization, fallbackMap);
+                TEST_FONT_DIR, updatableFontMap, oemCustomization, fallbackMap);
         Typeface.initSystemDefaultTypefaces(fontMap, fallbackMap, aliases);
     }
 
diff --git a/graphics/java/android/graphics/FontListParser.java b/graphics/java/android/graphics/FontListParser.java
index 73fff72..af100c9 100644
--- a/graphics/java/android/graphics/FontListParser.java
+++ b/graphics/java/android/graphics/FontListParser.java
@@ -31,6 +31,7 @@
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
 
 /**
@@ -50,19 +51,21 @@
      * Parse the fonts.xml
      */
     public static FontConfig parse(InputStream in, String fontDir,
-            @Nullable String updatableFontDir) throws XmlPullParserException, IOException {
+            @Nullable Map<String, File> updatableFontMap)
+            throws XmlPullParserException, IOException {
         try {
             XmlPullParser parser = Xml.newPullParser();
             parser.setInput(in, null);
             parser.nextTag();
-            return readFamilies(parser, fontDir, updatableFontDir);
+            return readFamilies(parser, fontDir, updatableFontMap);
         } finally {
             in.close();
         }
     }
 
     private static FontConfig readFamilies(XmlPullParser parser, String fontDir,
-            @Nullable String updatableFontDir) throws XmlPullParserException, IOException {
+            @Nullable Map<String, File> updatableFontMap)
+            throws XmlPullParserException, IOException {
         List<FontConfig.Family> families = new ArrayList<>();
         List<FontConfig.Alias> aliases = new ArrayList<>();
 
@@ -71,7 +74,7 @@
             if (parser.getEventType() != XmlPullParser.START_TAG) continue;
             String tag = parser.getName();
             if (tag.equals("family")) {
-                families.add(readFamily(parser, fontDir, updatableFontDir));
+                families.add(readFamily(parser, fontDir, updatableFontMap));
             } else if (tag.equals("alias")) {
                 aliases.add(readAlias(parser));
             } else {
@@ -86,7 +89,8 @@
      * Reads a family element
      */
     public static FontConfig.Family readFamily(XmlPullParser parser, String fontDir,
-            @Nullable String updatableFontDir) throws XmlPullParserException, IOException {
+            @Nullable Map<String, File> updatableFontMap)
+            throws XmlPullParserException, IOException {
         final String name = parser.getAttributeValue(null, "name");
         final String lang = parser.getAttributeValue("", "lang");
         final String variant = parser.getAttributeValue(null, "variant");
@@ -95,7 +99,7 @@
             if (parser.getEventType() != XmlPullParser.START_TAG) continue;
             final String tag = parser.getName();
             if (tag.equals("font")) {
-                fonts.add(readFont(parser, fontDir, updatableFontDir));
+                fonts.add(readFont(parser, fontDir, updatableFontMap));
             } else {
                 skip(parser);
             }
@@ -117,7 +121,8 @@
             Pattern.compile("^[ \\n\\r\\t]+|[ \\n\\r\\t]+$");
 
     private static FontConfig.Font readFont(XmlPullParser parser, String fontDir,
-            @Nullable String updatableFontDir) throws XmlPullParserException, IOException {
+            @Nullable Map<String, File> updatableFontMap)
+            throws XmlPullParserException, IOException {
         String indexStr = parser.getAttributeValue(null, "index");
         int index = indexStr == null ? 0 : Integer.parseInt(indexStr);
         List<FontVariationAxis> axes = new ArrayList<FontVariationAxis>();
@@ -139,20 +144,20 @@
             }
         }
         String sanitizedName = FILENAME_WHITESPACE_PATTERN.matcher(filename).replaceAll("");
-        String fontName = findFontFile(sanitizedName, fontDir, updatableFontDir);
+        String fontName = findFontFile(sanitizedName, fontDir, updatableFontMap);
         return new FontConfig.Font(fontName, index, axes.toArray(
                 new FontVariationAxis[axes.size()]), weight, isItalic, fallbackFor);
     }
 
-    private static String findFontFile(String fileName, String fontDir,
-            @Nullable String updatableFontDir) {
-        if (updatableFontDir != null) {
-            String updatableFontName = updatableFontDir + fileName;
-            if (new File(updatableFontName).exists()) {
-                return updatableFontName;
+    private static String findFontFile(String name, String fontDir,
+            @Nullable Map<String, File> updatableFontMap) {
+        if (updatableFontMap != null) {
+            File updatedFile = updatableFontMap.get(name);
+            if (updatedFile != null) {
+                return updatedFile.getAbsolutePath();
             }
         }
-        return fontDir + fileName;
+        return fontDir + name;
     }
 
     private static FontVariationAxis readAxis(XmlPullParser parser)
diff --git a/graphics/java/android/graphics/fonts/SystemFonts.java b/graphics/java/android/graphics/fonts/SystemFonts.java
index 16a53c2..93b1fcc 100644
--- a/graphics/java/android/graphics/fonts/SystemFonts.java
+++ b/graphics/java/android/graphics/fonts/SystemFonts.java
@@ -92,7 +92,7 @@
                 readFontCustomization("/product/etc/fonts_customization.xml", "/product/fonts/");
         Map<String, FontFamily[]> map = new ArrayMap<>();
         // TODO: use updated fonts
-        buildSystemFallback("/system/etc/fonts.xml", "/system/fonts/", null /* updatableFontDir */,
+        buildSystemFallback("/system/etc/fonts.xml", "/system/fonts/", null /* updatableFontMap */,
                 oemCustomization, map);
         Set<Font> res = new HashSet<>();
         for (FontFamily[] families : map.values()) {
@@ -228,7 +228,7 @@
     }
 
     /**
-     * @see #buildSystemFallback(String, String, String, FontCustomizationParser.Result, Map)
+     * @see #buildSystemFallback(String, String, Map, FontCustomizationParser.Result, Map)
      * @hide
      */
     @VisibleForTesting
@@ -236,7 +236,7 @@
             @NonNull String fontDir,
             @NonNull FontCustomizationParser.Result oemCustomization,
             @NonNull Map<String, FontFamily[]> fallbackMap) {
-        return buildSystemFallback(xmlPath, fontDir, null /* updatableFontDir */,
+        return buildSystemFallback(xmlPath, fontDir, null /* updatableFontMap */,
                 oemCustomization, fallbackMap);
     }
 
@@ -246,8 +246,7 @@
      * @param xmlPath A full path string to the fonts.xml file.
      * @param fontDir A full path string to the system font directory. This must end with
      *                slash('/').
-     * @param updatableFontDir A full path string to the updatable system font directory. This
-     *                           must end with slash('/').
+     * @param updatableFontMap A map from font file name to updated font file path.
      * @param fallbackMap An output system fallback map. Caller must pass empty map.
      * @return a list of aliases
      * @hide
@@ -255,12 +254,12 @@
     @VisibleForTesting
     public static FontConfig.Alias[] buildSystemFallback(@NonNull String xmlPath,
             @NonNull String fontDir,
-            @Nullable String updatableFontDir,
+            @Nullable Map<String, File> updatableFontMap,
             @NonNull FontCustomizationParser.Result oemCustomization,
             @NonNull Map<String, FontFamily[]> fallbackMap) {
         try {
             final FileInputStream fontsIn = new FileInputStream(xmlPath);
-            final FontConfig fontConfig = FontListParser.parse(fontsIn, fontDir, updatableFontDir);
+            final FontConfig fontConfig = FontListParser.parse(fontsIn, fontDir, updatableFontMap);
 
             final HashMap<String, ByteBuffer> bufferCache = new HashMap<String, ByteBuffer>();
             final FontConfig.Family[] xmlFamilies = fontConfig.getFamilies();
@@ -329,12 +328,12 @@
 
     /** @hide */
     public static Pair<FontConfig.Alias[], Map<String, FontFamily[]>>
-            initializeSystemFonts(@Nullable String updatableFontDir) {
+            initializeSystemFonts(@Nullable Map<String, File> updatableFontMap) {
         final FontCustomizationParser.Result oemCustomization =
                 readFontCustomization("/product/etc/fonts_customization.xml", "/product/fonts/");
         Map<String, FontFamily[]> map = new ArrayMap<>();
         FontConfig.Alias[] aliases = buildSystemFallback("/system/etc/fonts.xml", "/system/fonts/",
-                updatableFontDir, oemCustomization, map);
+                updatableFontMap, oemCustomization, map);
         synchronized (LOCK) {
             sFamilyMap = map;
         }
diff --git a/services/core/java/com/android/server/graphics/fonts/FontManagerService.java b/services/core/java/com/android/server/graphics/fonts/FontManagerService.java
index 521ce69..633c0c4 100644
--- a/services/core/java/com/android/server/graphics/fonts/FontManagerService.java
+++ b/services/core/java/com/android/server/graphics/fonts/FontManagerService.java
@@ -20,15 +20,28 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.graphics.Typeface;
+import android.graphics.fonts.FontFamily;
+import android.graphics.fonts.FontFileUtil;
+import android.graphics.fonts.SystemFonts;
 import android.os.SharedMemory;
 import android.system.ErrnoException;
+import android.text.FontConfig;
+import android.util.Pair;
 import android.util.Slog;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.server.LocalServices;
 import com.android.server.SystemService;
 
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.NioUtils;
+import java.nio.channels.FileChannel;
+import java.util.HashMap;
+import java.util.Map;
 
 /** A service for managing system fonts. */
 // TODO(b/173619554): Add API to update fonts.
@@ -36,6 +49,10 @@
 
     private static final String TAG = "FontManagerService";
 
+    // TODO: make this a DeviceConfig flag.
+    private static final boolean ENABLE_FONT_UPDATES = false;
+    private static final String FONT_FILES_DIR = "/data/fonts/files";
+
     /** Class to manage FontManagerService's lifecycle. */
     public static final class Lifecycle extends SystemService {
         private final FontManagerService mService;
@@ -58,10 +75,37 @@
         }
     }
 
-    @GuardedBy("this")
+    private static class OtfFontFileParser implements UpdatableFontDir.FontFileParser {
+        @Override
+        public long getVersion(File file) throws IOException {
+            ByteBuffer buffer = mmap(file);
+            try {
+                return FontFileUtil.getRevision(buffer, 0);
+            } finally {
+                NioUtils.freeDirectBuffer(buffer);
+            }
+        }
+
+        private static ByteBuffer mmap(File file) throws IOException {
+            try (FileInputStream in = new FileInputStream(file)) {
+                FileChannel fileChannel = in.getChannel();
+                return fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
+            }
+        }
+    }
+
+    @Nullable
+    private final UpdatableFontDir mUpdatableFontDir;
+
+    @GuardedBy("FontManagerService.this")
     @Nullable
     private SharedMemory mSerializedSystemFontMap = null;
 
+    private FontManagerService() {
+        mUpdatableFontDir = ENABLE_FONT_UPDATES
+                ? new UpdatableFontDir(new File(FONT_FILES_DIR), new OtfFontFileParser()) : null;
+    }
+
     @Nullable
     private SharedMemory getSerializedSystemFontMap() {
         if (!Typeface.ENABLE_LAZY_TYPEFACE_INITIALIZATION) {
@@ -77,7 +121,19 @@
 
     @Nullable
     private SharedMemory createSerializedSystemFontMapLocked() {
-        // TODO(b/173619554): use updated fonts.
+        if (mUpdatableFontDir != null) {
+            HashMap<String, Typeface> systemFontMap = new HashMap<>();
+            Map<String, File> fontFileMap = mUpdatableFontDir.getFontFileMap();
+            Pair<FontConfig.Alias[], Map<String, FontFamily[]>> pair =
+                    SystemFonts.initializeSystemFonts(fontFileMap);
+            Typeface.initSystemDefaultTypefaces(systemFontMap, pair.second, pair.first);
+            try {
+                return Typeface.serializeFontMap(systemFontMap);
+            } catch (IOException | ErrnoException e) {
+                Slog.w(TAG, "Failed to serialize updatable font map. "
+                        + "Retrying with system image fonts.", e);
+            }
+        }
         try {
             return Typeface.serializeFontMap(Typeface.getSystemFontMap());
         } catch (IOException | ErrnoException e) {
@@ -85,4 +141,19 @@
         }
         return null;
     }
+
+    private boolean installFontFile(String name, FileDescriptor fd) {
+        if (mUpdatableFontDir == null) return false;
+        synchronized (FontManagerService.this) {
+            try {
+                mUpdatableFontDir.installFontFile(name, fd);
+            } catch (IOException e) {
+                Slog.w(TAG, "Failed to install font file: " + name, e);
+                return false;
+            }
+            // Create updated font map in the next getSerializedSystemFontMap() call.
+            mSerializedSystemFontMap = null;
+            return true;
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/graphics/fonts/UpdatableFontDir.java b/services/core/java/com/android/server/graphics/fonts/UpdatableFontDir.java
new file mode 100644
index 0000000..7306471
--- /dev/null
+++ b/services/core/java/com/android/server/graphics/fonts/UpdatableFontDir.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2021 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.server.graphics.fonts;
+
+import android.os.FileUtils;
+import android.util.Base64;
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.HashMap;
+import java.util.Map;
+
+final class UpdatableFontDir {
+
+    private static final String TAG = "UpdatableFontDir";
+    private static final String RANDOM_DIR_PREFIX = "~~";
+
+    /** Interface to mock font file access in tests. */
+    interface FontFileParser {
+        long getVersion(File file) throws IOException;
+    }
+
+    /** Data class to hold font file path and version. */
+    static final class FontFileInfo {
+        final File mFile;
+        final long mVersion;
+
+        FontFileInfo(File file, long version) {
+            mFile = file;
+            mVersion = version;
+        }
+    }
+
+    /**
+     * Root directory for storing updated font files. Each font file is stored in a unique random
+     * dir. The font file path would be {@code mFilesDir/~~{randomStr}/{fontFileName}}.
+     */
+    private final File mFilesDir;
+    private final FontFileParser mParser;
+    @GuardedBy("UpdatableFontDir.this")
+    private final Map<String, FontFileInfo> mFontFileInfoMap = new HashMap<>();
+
+    UpdatableFontDir(File filesDir, FontFileParser parser) {
+        mFilesDir = filesDir;
+        mParser = parser;
+        loadFontFileMap();
+    }
+
+    private void loadFontFileMap() {
+        synchronized (UpdatableFontDir.this) {
+            mFontFileInfoMap.clear();
+            File[] dirs = mFilesDir.listFiles();
+            if (dirs == null) return;
+            for (File dir : dirs) {
+                if (!dir.getName().startsWith(RANDOM_DIR_PREFIX)) continue;
+                File[] files = dir.listFiles();
+                if (files == null || files.length != 1) continue;
+                addFileToMapLocked(files[0], true);
+            }
+        }
+    }
+
+    void installFontFile(String name, FileDescriptor fd) throws IOException {
+        // TODO: Validate name.
+        synchronized (UpdatableFontDir.this) {
+            // TODO: proper error handling
+            File newDir = getRandomDir(mFilesDir);
+            if (!newDir.mkdir()) {
+                throw new IOException("Failed to create a new dir");
+            }
+            File newFontFile = new File(newDir, name);
+            try (FileOutputStream out = new FileOutputStream(newFontFile)) {
+                FileUtils.copy(fd, out.getFD());
+            }
+            addFileToMapLocked(newFontFile, false);
+        }
+    }
+
+    /**
+     * Given {@code parent}, returns {@code parent/~~[randomStr]}.
+     * Makes sure that {@code parent/~~[randomStr]} directory doesn't exist.
+     * Notice that this method doesn't actually create any directory.
+     */
+    private static File getRandomDir(File parent) {
+        SecureRandom random = new SecureRandom();
+        byte[] bytes = new byte[16];
+        File dir;
+        do {
+            random.nextBytes(bytes);
+            String dirName = RANDOM_DIR_PREFIX
+                    + Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP);
+            dir = new File(parent, dirName);
+        } while (dir.exists());
+        return dir;
+    }
+
+    private void addFileToMapLocked(File file, boolean deleteOldFile) {
+        final long version;
+        try {
+            version = mParser.getVersion(file);
+        } catch (IOException e) {
+            Slog.e(TAG, "Failed to read font file", e);
+            return;
+        }
+        if (version == -1) {
+            Slog.e(TAG, "Invalid font file");
+            return;
+        }
+        FontFileInfo info = mFontFileInfoMap.get(file.getName());
+        if (info == null) {
+            // TODO: check version of font in /system/fonts and /product/fonts
+            mFontFileInfoMap.put(file.getName(), new FontFileInfo(file, version));
+        } else if (info.mVersion < version) {
+            if (deleteOldFile) {
+                FileUtils.deleteContentsAndDir(info.mFile.getParentFile());
+            }
+            mFontFileInfoMap.put(file.getName(), new FontFileInfo(file, version));
+        }
+    }
+
+    Map<String, File> getFontFileMap() {
+        Map<String, File> map = new HashMap<>();
+        synchronized (UpdatableFontDir.this) {
+            for (Map.Entry<String, FontFileInfo> entry : mFontFileInfoMap.entrySet()) {
+                map.put(entry.getKey(), entry.getValue().mFile);
+            }
+        }
+        return map;
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/graphics/fonts/UpdatableFontDirTest.java b/services/tests/servicestests/src/com/android/server/graphics/fonts/UpdatableFontDirTest.java
new file mode 100644
index 0000000..0cf0af3
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/graphics/fonts/UpdatableFontDirTest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2021 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.server.graphics.fonts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.content.Context;
+import android.os.FileUtils;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Map;
+
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class UpdatableFontDirTest {
+
+    /**
+     * A {@link UpdatableFontDir.FontFileParser} for testing. Instead of using real font files,
+     * this test uses fake font files. A fake font file has its version as its file content.
+     */
+    private static class FakeFontFileParser implements UpdatableFontDir.FontFileParser {
+        @Override
+        public long getVersion(File file) throws IOException {
+            return Long.parseLong(FileUtils.readTextFile(file, 100, ""));
+        }
+    }
+
+    private File mCacheDir;
+    private File mUpdatableFontFilesDir;
+
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    @Before
+    public void setUp() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        mCacheDir = new File(context.getCacheDir(), "UpdatableFontDirTest");
+        FileUtils.deleteContentsAndDir(mCacheDir);
+        mCacheDir.mkdirs();
+        mUpdatableFontFilesDir = new File(mCacheDir, "updatable_fonts");
+        mUpdatableFontFilesDir.mkdir();
+    }
+
+    @After
+    public void tearDown() {
+        FileUtils.deleteContentsAndDir(mCacheDir);
+    }
+
+    @Test
+    public void construct() throws Exception {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dirForPreparation = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+        installFontFile(dirForPreparation, "foo.ttf", "1");
+        installFontFile(dirForPreparation, "bar.ttf", "2");
+        installFontFile(dirForPreparation, "foo.ttf", "3");
+        installFontFile(dirForPreparation, "bar.ttf", "4");
+        // Four font dirs are created.
+        assertThat(mUpdatableFontFilesDir.list()).hasLength(4);
+
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+        assertThat(dir.getFontFileMap()).containsKey("foo.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("foo.ttf"))).isEqualTo(3);
+        assertThat(dir.getFontFileMap()).containsKey("bar.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("bar.ttf"))).isEqualTo(4);
+        // Outdated font dir should be deleted.
+        assertThat(mUpdatableFontFilesDir.list()).hasLength(2);
+    }
+
+    @Test
+    public void construct_empty() {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+        assertThat(dir.getFontFileMap()).isEmpty();
+    }
+
+    @Test
+    public void installFontFile() throws Exception {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+
+        installFontFile(dir, "test.ttf", "1");
+        assertThat(dir.getFontFileMap()).containsKey("test.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("test.ttf"))).isEqualTo(1);
+    }
+
+    @Test
+    public void installFontFile_upgrade() throws Exception {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+
+        installFontFile(dir, "test.ttf", "1");
+        Map<String, File> mapBeforeUpgrade = dir.getFontFileMap();
+        installFontFile(dir, "test.ttf", "2");
+        assertThat(dir.getFontFileMap()).containsKey("test.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("test.ttf"))).isEqualTo(2);
+        assertThat(mapBeforeUpgrade).containsKey("test.ttf");
+        assertWithMessage("Older fonts should not be deleted until next loadFontFileMap")
+                .that(parser.getVersion(mapBeforeUpgrade.get("test.ttf"))).isEqualTo(1);
+    }
+
+    @Test
+    public void installFontFile_downgrade() throws Exception {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+
+        installFontFile(dir, "test.ttf", "2");
+        installFontFile(dir, "test.ttf", "1");
+        assertThat(dir.getFontFileMap()).containsKey("test.ttf");
+        assertWithMessage("Font should not be downgraded to an older version")
+                .that(parser.getVersion(dir.getFontFileMap().get("test.ttf"))).isEqualTo(2);
+    }
+
+    @Test
+    public void installFontFile_multiple() throws Exception {
+        FakeFontFileParser parser = new FakeFontFileParser();
+        UpdatableFontDir dir = new UpdatableFontDir(mUpdatableFontFilesDir, parser);
+
+        installFontFile(dir, "foo.ttf", "1");
+        installFontFile(dir, "bar.ttf", "2");
+        assertThat(dir.getFontFileMap()).containsKey("foo.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("foo.ttf"))).isEqualTo(1);
+        assertThat(dir.getFontFileMap()).containsKey("bar.ttf");
+        assertThat(parser.getVersion(dir.getFontFileMap().get("bar.ttf"))).isEqualTo(2);
+    }
+
+    private void installFontFile(UpdatableFontDir dir, String name, String content)
+            throws IOException {
+        File file = File.createTempFile(name, "", mCacheDir);
+        FileUtils.stringToFile(file, content);
+        try (FileInputStream in = new FileInputStream(file)) {
+            dir.installFontFile(name, in.getFD());
+        }
+    }
+}