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