• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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.updatablesystemfont;
18 
19 import static android.graphics.fonts.FontStyle.FONT_SLANT_UPRIGHT;
20 import static android.graphics.fonts.FontStyle.FONT_WEIGHT_BOLD;
21 import static android.graphics.fonts.FontStyle.FONT_WEIGHT_NORMAL;
22 import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
23 
24 import static com.google.common.truth.Truth.assertThat;
25 
26 import static org.junit.Assert.assertThrows;
27 
28 import static java.util.concurrent.TimeUnit.SECONDS;
29 
30 import android.app.UiAutomation;
31 import android.content.Context;
32 import android.graphics.fonts.FontFamilyUpdateRequest;
33 import android.graphics.fonts.FontFileUpdateRequest;
34 import android.graphics.fonts.FontManager;
35 import android.graphics.fonts.FontStyle;
36 import android.os.ParcelFileDescriptor;
37 import android.platform.test.annotations.RootPermissionTest;
38 import android.text.FontConfig;
39 import android.util.Log;
40 import android.util.Pair;
41 
42 import androidx.annotation.Nullable;
43 import androidx.test.ext.junit.runners.AndroidJUnit4;
44 import androidx.test.platform.app.InstrumentationRegistry;
45 import androidx.test.uiautomator.By;
46 import androidx.test.uiautomator.UiDevice;
47 import androidx.test.uiautomator.Until;
48 
49 import com.android.compatibility.common.util.StreamUtil;
50 import com.android.compatibility.common.util.SystemUtil;
51 
52 import org.junit.After;
53 import org.junit.Before;
54 import org.junit.Test;
55 import org.junit.runner.RunWith;
56 
57 import java.io.File;
58 import java.io.FileInputStream;
59 import java.io.FileOutputStream;
60 import java.io.IOException;
61 import java.io.InputStream;
62 import java.io.OutputStream;
63 import java.nio.file.Files;
64 import java.nio.file.Paths;
65 import java.util.ArrayList;
66 import java.util.Arrays;
67 import java.util.Collections;
68 import java.util.List;
69 import java.util.regex.Pattern;
70 import java.util.stream.Stream;
71 
72 /**
73  * Tests if fonts can be updated by {@link FontManager} API.
74  */
75 @RootPermissionTest
76 @RunWith(AndroidJUnit4.class)
77 public class UpdatableSystemFontTest {
78 
79     private static final String TAG = "UpdatableSystemFontTest";
80     private static final String SYSTEM_FONTS_DIR = "/system/fonts/";
81     private static final String DATA_FONTS_DIR = "/data/fonts/files/";
82     private static final String CERT_PATH = "/data/local/tmp/UpdatableSystemFontTestCert.der";
83 
84     private static final String NOTO_COLOR_EMOJI_POSTSCRIPT_NAME = "NotoColorEmoji";
85     private static final String NOTO_COLOR_EMOJI_TTF =
86             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmoji.ttf";
87     private static final String NOTO_COLOR_EMOJI_SIG =
88             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmoji.sig";
89     // A font with revision == 0.
90     private static final String TEST_NOTO_COLOR_EMOJI_V0_TTF =
91             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiV0.ttf";
92     private static final String TEST_NOTO_COLOR_EMOJI_V0_SIG =
93             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiV0.sig";
94     // A font with revision == original + 1
95     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF =
96             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiVPlus1.ttf";
97     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG =
98             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiVPlus1.sig";
99     // A font with revision == original + 2
100     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF =
101             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiVPlus2.ttf";
102     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS2_SIG =
103             "/data/local/tmp/UpdatableSystemFontTest_NotoColorEmojiVPlus2.sig";
104 
105     private static final String NOTO_SERIF_REGULAR_POSTSCRIPT_NAME = "NotoSerif";
106     private static final String NOTO_SERIF_REGULAR_TTF =
107             "/data/local/tmp/UpdatableSystemFontTest_NotoSerif-Regular.ttf";
108     private static final String NOTO_SERIF_REGULAR_SIG =
109             "/data/local/tmp/UpdatableSystemFontTest_NotoSerif-Regular.sig";
110 
111     private static final String NOTO_SERIF_BOLD_POSTSCRIPT_NAME = "NotoSerif-Bold";
112     private static final String NOTO_SERIF_BOLD_TTF =
113             "/data/local/tmp/UpdatableSystemFontTest_NotoSerif-Bold.ttf";
114     private static final String NOTO_SERIF_BOLD_SIG =
115             "/data/local/tmp/UpdatableSystemFontTest_NotoSerif-Bold.sig";
116 
117     private static final String EMOJI_RENDERING_TEST_APP_ID = "com.android.emojirenderingtestapp";
118     private static final String EMOJI_RENDERING_TEST_ACTIVITY =
119             EMOJI_RENDERING_TEST_APP_ID + "/.EmojiRenderingTestActivity";
120     // This should be the same as the one in EmojiRenderingTestActivity.
121     private static final String TEST_NOTO_SERIF = "test-noto-serif";
122     private static final long ACTIVITY_TIMEOUT_MILLIS = SECONDS.toMillis(10);
123 
124     private static final String GET_AVAILABLE_FONTS_TEST_ACTIVITY =
125             EMOJI_RENDERING_TEST_APP_ID + "/.GetAvailableFontsTestActivity";
126 
127     private static final Pattern PATTERN_FONT_FILES = Pattern.compile("\\.(ttf|otf|ttc|otc)$");
128     private static final Pattern PATTERN_TMP_FILES = Pattern.compile("^/data/local/tmp/");
129     private static final Pattern PATTERN_DATA_FONT_FILES = Pattern.compile("^/data/fonts/files/");
130     private static final Pattern PATTERN_SYSTEM_FONT_FILES =
131             Pattern.compile("^/(system|product)/fonts/");
132 
133     private FontManager mFontManager;
134     private UiDevice mUiDevice;
135 
136     @Before
setUp()137     public void setUp() throws Exception {
138         Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
139         insertCert(CERT_PATH);
140         mFontManager = context.getSystemService(FontManager.class);
141         expectCommandToSucceed("cmd font clear");
142         mUiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
143     }
144 
145     @After
tearDown()146     public void tearDown() throws Exception {
147         // Ignore errors because this may fail if updatable system font is not enabled.
148         runShellCommand("cmd font clear", null);
149     }
150 
151     @Test
updateFont()152     public void updateFont() throws Exception {
153         FontConfig oldFontConfig =
154                 SystemUtil.callWithShellPermissionIdentity(mFontManager::getFontConfig);
155         assertThat(updateFontFile(
156                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
157                 .isEqualTo(FontManager.RESULT_SUCCESS);
158         // Check that font config is updated.
159         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
160         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
161         FontConfig newFontConfig =
162                 SystemUtil.callWithShellPermissionIdentity(mFontManager::getFontConfig);
163         assertThat(newFontConfig.getConfigVersion())
164                 .isGreaterThan(oldFontConfig.getConfigVersion());
165         assertThat(newFontConfig.getLastModifiedTimeMillis())
166                 .isGreaterThan(oldFontConfig.getLastModifiedTimeMillis());
167         // The updated font should be readable and unmodifiable.
168         expectCommandToSucceed("dd status=none if=" + fontPath + " of=/dev/null");
169         expectCommandToFail("dd status=none if=" + CERT_PATH + " of=" + fontPath);
170     }
171 
172     @Test
updateFont_twice()173     public void updateFont_twice() throws Exception {
174         assertThat(updateFontFile(
175                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
176                 .isEqualTo(FontManager.RESULT_SUCCESS);
177         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
178         assertThat(updateFontFile(
179                 TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_SIG))
180                 .isEqualTo(FontManager.RESULT_SUCCESS);
181         String fontPath2 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
182         assertThat(fontPath2).startsWith(DATA_FONTS_DIR);
183         assertThat(fontPath2).isNotEqualTo(fontPath);
184         // The new file should be readable.
185         expectCommandToSucceed("dd status=none if=" + fontPath2 + " of=/dev/null");
186         // The old file should be still readable.
187         expectCommandToSucceed("dd status=none if=" + fontPath + " of=/dev/null");
188     }
189 
190     @Test
updateFont_allowSameVersion()191     public void updateFont_allowSameVersion() throws Exception {
192         // Update original font to the same version
193         assertThat(updateFontFile(
194                 NOTO_COLOR_EMOJI_TTF, NOTO_COLOR_EMOJI_SIG))
195                 .isEqualTo(FontManager.RESULT_SUCCESS);
196         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
197         assertThat(updateFontFile(
198                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
199                 .isEqualTo(FontManager.RESULT_SUCCESS);
200         String fontPath2 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
201         // Update updated font to the same version
202         assertThat(updateFontFile(
203                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
204                 .isEqualTo(FontManager.RESULT_SUCCESS);
205         String fontPath3 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
206         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
207         assertThat(fontPath2).isNotEqualTo(fontPath);
208         assertThat(fontPath2).startsWith(DATA_FONTS_DIR);
209         assertThat(fontPath3).startsWith(DATA_FONTS_DIR);
210         assertThat(fontPath3).isNotEqualTo(fontPath);
211     }
212 
213     @Test
updateFont_invalidCert()214     public void updateFont_invalidCert() throws Exception {
215         assertThat(updateFontFile(
216                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_SIG))
217                 .isEqualTo(FontManager.RESULT_ERROR_VERIFICATION_FAILURE);
218     }
219 
220     @Test
updateFont_downgradeFromSystem()221     public void updateFont_downgradeFromSystem() throws Exception {
222         assertThat(updateFontFile(
223                 TEST_NOTO_COLOR_EMOJI_V0_TTF, TEST_NOTO_COLOR_EMOJI_V0_SIG))
224                 .isEqualTo(FontManager.RESULT_ERROR_DOWNGRADING);
225     }
226 
227     @Test
updateFont_downgradeFromData()228     public void updateFont_downgradeFromData() throws Exception {
229         assertThat(updateFontFile(
230                 TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_SIG))
231                 .isEqualTo(FontManager.RESULT_SUCCESS);
232         assertThat(updateFontFile(
233                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
234                 .isEqualTo(FontManager.RESULT_ERROR_DOWNGRADING);
235     }
236 
237     @Test
updateFontFamily()238     public void updateFontFamily() throws Exception {
239         assertThat(updateNotoSerifAs("serif")).isEqualTo(FontManager.RESULT_SUCCESS);
240         final FontConfig.NamedFamilyList namedFamilyList = findFontFamilyOrThrow("serif");
241         assertThat(namedFamilyList.getFamilies().size()).isEqualTo(1);
242         final FontConfig.FontFamily family = namedFamilyList.getFamilies().get(0);
243 
244         assertThat(family.getFontList()).hasSize(2);
245         assertThat(family.getFontList().get(0).getPostScriptName())
246                 .isEqualTo(NOTO_SERIF_REGULAR_POSTSCRIPT_NAME);
247         assertThat(family.getFontList().get(0).getFile().getAbsolutePath())
248                 .startsWith(DATA_FONTS_DIR);
249         assertThat(family.getFontList().get(0).getStyle().getWeight())
250                 .isEqualTo(FONT_WEIGHT_NORMAL);
251         assertThat(family.getFontList().get(1).getPostScriptName())
252                 .isEqualTo(NOTO_SERIF_BOLD_POSTSCRIPT_NAME);
253         assertThat(family.getFontList().get(1).getFile().getAbsolutePath())
254                 .startsWith(DATA_FONTS_DIR);
255         assertThat(family.getFontList().get(1).getStyle().getWeight()).isEqualTo(FONT_WEIGHT_BOLD);
256     }
257 
258     @Test
updateFontFamily_asNewFont()259     public void updateFontFamily_asNewFont() throws Exception {
260         assertThat(updateNotoSerifAs("UpdatableSystemFontTest-serif"))
261                 .isEqualTo(FontManager.RESULT_SUCCESS);
262         final FontConfig.NamedFamilyList namedFamilyList =
263                 findFontFamilyOrThrow("UpdatableSystemFontTest-serif");
264         assertThat(namedFamilyList.getFamilies().size()).isEqualTo(1);
265         final FontConfig.FontFamily family = namedFamilyList.getFamilies().get(0);
266         assertThat(family.getFontList()).hasSize(2);
267         assertThat(family.getFontList().get(0).getPostScriptName())
268                 .isEqualTo(NOTO_SERIF_REGULAR_POSTSCRIPT_NAME);
269         assertThat(family.getFontList().get(1).getPostScriptName())
270                 .isEqualTo(NOTO_SERIF_BOLD_POSTSCRIPT_NAME);
271     }
272 
273     @Test
launchApp()274     public void launchApp() throws Exception {
275         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
276         assertThat(fontPath).startsWith(SYSTEM_FONTS_DIR);
277         startActivity(EMOJI_RENDERING_TEST_APP_ID, EMOJI_RENDERING_TEST_ACTIVITY);
278         SystemUtil.eventually(
279                 () -> assertThat(isFileOpenedBy(fontPath, EMOJI_RENDERING_TEST_APP_ID)).isTrue(),
280                 ACTIVITY_TIMEOUT_MILLIS);
281     }
282 
283     @Test
launchApp_afterUpdateFont()284     public void launchApp_afterUpdateFont() throws Exception {
285         String originalFontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
286         assertThat(originalFontPath).startsWith(SYSTEM_FONTS_DIR);
287         assertThat(updateFontFile(
288                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
289                 .isEqualTo(FontManager.RESULT_SUCCESS);
290         String updatedFontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
291         assertThat(updatedFontPath).startsWith(DATA_FONTS_DIR);
292         updateNotoSerifAs(TEST_NOTO_SERIF);
293         String notoSerifPath = getFontPath(NOTO_SERIF_REGULAR_POSTSCRIPT_NAME);
294         startActivity(EMOJI_RENDERING_TEST_APP_ID, EMOJI_RENDERING_TEST_ACTIVITY);
295         // The original font should NOT be opened by the app.
296         SystemUtil.eventually(() -> {
297             assertThat(isFileOpenedBy(updatedFontPath, EMOJI_RENDERING_TEST_APP_ID)).isTrue();
298             assertThat(isFileOpenedBy(originalFontPath, EMOJI_RENDERING_TEST_APP_ID)).isFalse();
299             assertThat(isFileOpenedBy(notoSerifPath, EMOJI_RENDERING_TEST_APP_ID)).isTrue();
300         }, ACTIVITY_TIMEOUT_MILLIS);
301     }
302 
303     @Test
reboot()304     public void reboot() throws Exception {
305         expectCommandToSucceed(String.format("cmd font update %s %s",
306                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG));
307         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
308         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
309 
310         // Emulate reboot by 'cmd font restart'.
311         expectCommandToSucceed("cmd font restart");
312         String fontPathAfterReboot = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
313         assertThat(fontPathAfterReboot).isEqualTo(fontPath);
314     }
315 
316     @Test
fdLeakTest()317     public void fdLeakTest() throws Exception {
318         long originalOpenFontCount =
319                 countMatch(getOpenFiles("system_server"), PATTERN_FONT_FILES);
320         Pattern patternEmojiVPlus1 =
321                 Pattern.compile(Pattern.quote(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF));
322         for (int i = 0; i < 10; i++) {
323             assertThat(updateFontFile(
324                     TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG))
325                     .isEqualTo(FontManager.RESULT_SUCCESS);
326             List<String> openFiles = getOpenFiles("system_server");
327             for (Pattern p : Arrays.asList(PATTERN_FONT_FILES, PATTERN_SYSTEM_FONT_FILES,
328                     PATTERN_DATA_FONT_FILES, PATTERN_TMP_FILES)) {
329                 Log.i(TAG, String.format("num of %s: %d", p, countMatch(openFiles, p)));
330             }
331             // system_server should not keep /data/fonts files open.
332             assertThat(countMatch(openFiles, PATTERN_DATA_FONT_FILES)).isEqualTo(0);
333             // system_server should not keep passed FD open.
334             assertThat(countMatch(openFiles, patternEmojiVPlus1)).isEqualTo(0);
335             // The number of open font FD should not increase.
336             assertThat(countMatch(openFiles, PATTERN_FONT_FILES))
337                     .isAtMost(originalOpenFontCount);
338         }
339     }
340 
341     @Test
fdLeakTest_withoutPermission()342     public void fdLeakTest_withoutPermission() throws Exception {
343         Pattern patternEmojiVPlus1 =
344                 Pattern.compile(Pattern.quote(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF));
345         byte[] signature = Files.readAllBytes(Paths.get(TEST_NOTO_COLOR_EMOJI_VPLUS1_SIG));
346         try (ParcelFileDescriptor fd = ParcelFileDescriptor.open(
347                 new File(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF), MODE_READ_ONLY)) {
348             assertThrows(SecurityException.class,
349                     () -> updateFontFileWithoutPermission(fd, signature, 0));
350         }
351         List<String> openFiles = getOpenFiles("system_server");
352         assertThat(countMatch(openFiles, patternEmojiVPlus1)).isEqualTo(0);
353     }
354 
355     @Test
getAvailableFonts()356     public void getAvailableFonts() throws Exception {
357         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
358         startActivity(EMOJI_RENDERING_TEST_APP_ID, GET_AVAILABLE_FONTS_TEST_ACTIVITY);
359         // GET_AVAILABLE_FONTS_TEST_ACTIVITY shows the NotoColorEmoji path it got.
360         mUiDevice.wait(
361                 Until.findObject(By.pkg(EMOJI_RENDERING_TEST_APP_ID).text(fontPath)),
362                 ACTIVITY_TIMEOUT_MILLIS);
363         // The font file should not be opened just by querying the path using
364         // SystemFont.getAvailableFonts().
365         assertThat(isFileOpenedBy(fontPath, EMOJI_RENDERING_TEST_APP_ID)).isFalse();
366     }
367 
insertCert(String certPath)368     private static void insertCert(String certPath) throws Exception {
369         // /data/local/tmp is not readable by system server. Copy a cert file to /data/fonts
370         final String copiedCert = "/data/fonts/debug_cert.der";
371         runShellCommand("cp " + certPath + " " + copiedCert, null);
372         runShellCommand("cmd font install-debug-cert " + copiedCert, null);
373     }
374 
updateFontFile(String fontPath, String signaturePath)375     private int updateFontFile(String fontPath, String signaturePath) throws IOException {
376         byte[] signature = Files.readAllBytes(Paths.get(signaturePath));
377         try (ParcelFileDescriptor fd =
378                 ParcelFileDescriptor.open(new File(fontPath), MODE_READ_ONLY)) {
379             return SystemUtil.runWithShellPermissionIdentity(() -> {
380                 int configVersion = mFontManager.getFontConfig().getConfigVersion();
381                 return updateFontFileWithoutPermission(fd, signature, configVersion);
382             });
383         }
384     }
385 
updateFontFileWithoutPermission(ParcelFileDescriptor fd, byte[] signature, int configVersion)386     private int updateFontFileWithoutPermission(ParcelFileDescriptor fd, byte[] signature,
387             int configVersion) {
388         return mFontManager.updateFontFamily(
389                 new FontFamilyUpdateRequest.Builder()
390                         .addFontFileUpdateRequest(new FontFileUpdateRequest(fd, signature))
391                         .build(),
392                 configVersion);
393     }
394 
updateNotoSerifAs(String familyName)395     private int updateNotoSerifAs(String familyName) throws IOException {
396         List<FontFamilyUpdateRequest.Font> fonts = Arrays.asList(
397                 new FontFamilyUpdateRequest.Font.Builder(NOTO_SERIF_REGULAR_POSTSCRIPT_NAME,
398                         new FontStyle(FONT_WEIGHT_NORMAL, FONT_SLANT_UPRIGHT)).build(),
399                 new FontFamilyUpdateRequest.Font.Builder(NOTO_SERIF_BOLD_POSTSCRIPT_NAME,
400                         new FontStyle(FONT_WEIGHT_BOLD, FONT_SLANT_UPRIGHT)).build());
401         FontFamilyUpdateRequest.FontFamily fontFamily =
402                 new FontFamilyUpdateRequest.FontFamily.Builder(familyName, fonts).build();
403         byte[] regularSig = Files.readAllBytes(Paths.get(NOTO_SERIF_REGULAR_SIG));
404         byte[] boldSig = Files.readAllBytes(Paths.get(NOTO_SERIF_BOLD_SIG));
405         try (ParcelFileDescriptor regularFd = ParcelFileDescriptor.open(
406                     new File(NOTO_SERIF_REGULAR_TTF), MODE_READ_ONLY);
407              ParcelFileDescriptor boldFd = ParcelFileDescriptor.open(
408                     new File(NOTO_SERIF_BOLD_TTF), MODE_READ_ONLY)) {
409             return SystemUtil.runWithShellPermissionIdentity(() -> {
410                 FontConfig fontConfig = mFontManager.getFontConfig();
411                 return mFontManager.updateFontFamily(new FontFamilyUpdateRequest.Builder()
412                         .addFontFileUpdateRequest(
413                                 new FontFileUpdateRequest(regularFd, regularSig))
414                         .addFontFileUpdateRequest(
415                                 new FontFileUpdateRequest(boldFd, boldSig))
416                         .addFontFamily(fontFamily)
417                         .build(), fontConfig.getConfigVersion());
418             });
419         }
420     }
421 
422     private String getFontPath(String psName) {
423         FontConfig fontConfig =
424                 SystemUtil.runWithShellPermissionIdentity(mFontManager::getFontConfig);
425         final List<FontConfig.FontFamily> namedFamilies = fontConfig.getNamedFamilyLists().stream()
426                 .flatMap(namedFamily -> namedFamily.getFamilies().stream()).toList();
427 
428         return Stream.concat(fontConfig.getFontFamilies().stream(), namedFamilies.stream())
429                 .flatMap(family -> family.getFontList().stream())
430                 .filter(font -> {
431                     Log.e("Debug", "PsName = " + font.getPostScriptName());
432                     return psName.equals(font.getPostScriptName());
433                 })
434                 // Return the last match, because the latter family takes precedence if two families
435                 // have the same name.
436                 .reduce((first, second) -> second)
437                 .orElseThrow(() -> new AssertionError("Font not found: " + psName))
438                 .getFile()
439                 .getAbsolutePath();
440     }
441 
442     private FontConfig.NamedFamilyList findFontFamilyOrThrow(String familyName) {
443         FontConfig fontConfig =
444                 SystemUtil.runWithShellPermissionIdentity(mFontManager::getFontConfig);
445         return fontConfig.getNamedFamilyLists().stream()
446                 .filter(family -> familyName.equals(family.getName()))
447                 // Return the last match, because the latter family takes precedence if two families
448                 // have the same name.
449                 .reduce((first, second) -> second)
450                 .orElseThrow(() -> new AssertionError("Family not found: " + familyName));
451     }
452 
453     private static void startActivity(String appId, String activityId) throws Exception {
454         expectCommandToSucceed("am force-stop " + appId);
455         expectCommandToSucceed("am start-activity -n " + activityId);
456     }
457 
458     private static String expectCommandToSucceed(String cmd) throws IOException {
459         Pair<String, String> result = runShellCommand(cmd, null);
460         // UiAutomation.runShellCommand() does not return exit code.
461         // Assume that the command fails if stderr is not empty.
462         assertThat(result.second.trim()).isEmpty();
463         return result.first;
464     }
465 
466     private static void expectCommandToFail(String cmd) throws IOException {
467         Pair<String, String> result = runShellCommand(cmd, null);
468         // UiAutomation.runShellCommand() does not return exit code.
469         // Assume that the command fails if stderr is not empty.
470         assertThat(result.second.trim()).isNotEmpty();
471     }
472 
473     /** Runs a command and returns (stdout, stderr). */
474     private static Pair<String, String> runShellCommand(String cmd, @Nullable InputStream input)
475             throws IOException  {
476         Log.i(TAG, "runShellCommand: " + cmd);
477         UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
478         ParcelFileDescriptor[] rwe = automation.executeShellCommandRwe(cmd);
479         // executeShellCommandRwe returns [stdout, stdin, stderr].
480         try (ParcelFileDescriptor outFd = rwe[0];
481              ParcelFileDescriptor inFd = rwe[1];
482              ParcelFileDescriptor errFd = rwe[2]) {
483             if (input != null) {
484                 try (OutputStream os = new FileOutputStream(inFd.getFileDescriptor())) {
485                     StreamUtil.copyStreams(input, os);
486                 }
487             }
488             // We have to close stdin before reading stdout and stderr.
489             // It's safe to close ParcelFileDescriptor multiple times.
490             inFd.close();
491             String stdout;
492             try (InputStream is = new FileInputStream(outFd.getFileDescriptor())) {
493                 stdout = StreamUtil.readInputStream(is);
494             }
495             Log.i(TAG, "stdout =  " + stdout);
496             String stderr;
497             try (InputStream is = new FileInputStream(errFd.getFileDescriptor())) {
498                 stderr = StreamUtil.readInputStream(is);
499             }
500             Log.i(TAG, "stderr =  " + stderr);
501             return new Pair<>(stdout, stderr);
502         }
503     }
504 
505     private static boolean isFileOpenedBy(String path, String appId) throws Exception {
506         String pid = pidOf(appId);
507         if (pid.isEmpty()) {
508             return false;
509         }
510         String cmd = String.format("lsof -t -p %s %s", pid, path);
511         return !expectCommandToSucceed(cmd).trim().isEmpty();
512     }
513 
514     private static List<String> getOpenFiles(String appId) throws Exception {
515         String pid = pidOf(appId);
516         if (pid.isEmpty()) {
517             return Collections.emptyList();
518         }
519         String cmd = String.format("lsof -p %s", pid);
520         String out = expectCommandToSucceed(cmd);
521         List<String> paths = new ArrayList<>();
522         boolean first = true;
523         for (String line : out.split("\n")) {
524             // Skip the header.
525             if (first) {
526                 first = false;
527                 continue;
528             }
529             String[] records = line.split(" ");
530             if (records.length > 0) {
531                 paths.add(records[records.length - 1]);
532             }
533         }
534         return paths;
535     }
536 
537     private static String pidOf(String appId) throws Exception {
538         return expectCommandToSucceed("pidof " + appId).trim();
539     }
540 
541     private static long countMatch(List<String> paths, Pattern pattern) {
542         // Note: asPredicate() returns true for partial matching.
543         return paths.stream()
544                 .filter(pattern.asPredicate())
545                 .count();
546     }
547 }
548