1 /*
2  * Copyright (C) 2018 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.textclassifier.common;
18 
19 import android.content.Context;
20 import android.content.res.AssetFileDescriptor;
21 import android.content.res.AssetManager;
22 import android.os.LocaleList;
23 import android.os.ParcelFileDescriptor;
24 import android.util.ArraySet;
25 import androidx.annotation.GuardedBy;
26 import androidx.collection.ArrayMap;
27 import com.android.textclassifier.common.ModelType.ModelTypeDef;
28 import com.android.textclassifier.common.base.TcLog;
29 import com.android.textclassifier.common.logging.ResultIdUtils.ModelInfo;
30 import com.android.textclassifier.utils.IndentingPrintWriter;
31 import com.google.android.textclassifier.ActionsSuggestionsModel;
32 import com.google.android.textclassifier.AnnotatorModel;
33 import com.google.android.textclassifier.LangIdModel;
34 import com.google.common.annotations.VisibleForTesting;
35 import com.google.common.base.Function;
36 import com.google.common.base.Optional;
37 import com.google.common.base.Preconditions;
38 import com.google.common.base.Supplier;
39 import com.google.common.collect.ImmutableList;
40 import java.io.File;
41 import java.io.IOException;
42 import java.util.Arrays;
43 import java.util.List;
44 import java.util.Locale;
45 import java.util.Map;
46 import java.util.Objects;
47 import java.util.regex.Matcher;
48 import java.util.regex.Pattern;
49 import java.util.stream.Collectors;
50 import javax.annotation.Nullable;
51 
52 // TODO(licha): Consider making this a singleton class
53 // TODO(licha): Check whether this is thread-safe
54 /**
55  * Manages all model files in storage. {@link TextClassifierImpl} depends on this class to get the
56  * model files to load.
57  */
58 public final class ModelFileManager {
59 
60   private static final String TAG = "ModelFileManager";
61 
62   private static final String DOWNLOAD_SUB_DIR_NAME = "textclassifier/downloads/models/";
63   private static final File CONFIG_UPDATER_DIR = new File("/data/misc/textclassifier/");
64   private static final String ASSETS_DIR = "textclassifier";
65 
66   private final List<ModelFileLister> modelFileListers;
67   private final File modelDownloaderDir;
68 
ModelFileManager(Context context, TextClassifierSettings settings)69   public ModelFileManager(Context context, TextClassifierSettings settings) {
70     Preconditions.checkNotNull(context);
71     Preconditions.checkNotNull(settings);
72 
73     AssetManager assetManager = context.getAssets();
74     this.modelDownloaderDir = new File(context.getFilesDir(), DOWNLOAD_SUB_DIR_NAME);
75     modelFileListers =
76         ImmutableList.of(
77             // Annotator models.
78             new RegularFilePatternMatchLister(
79                 ModelType.ANNOTATOR,
80                 this.modelDownloaderDir,
81                 "annotator\\.(.*)\\.model",
82                 settings::isModelDownloadManagerEnabled),
83             new RegularFileFullMatchLister(
84                 ModelType.ANNOTATOR,
85                 new File(CONFIG_UPDATER_DIR, "textclassifier.model"),
86                 /* isEnabled= */ () -> true),
87             new AssetFilePatternMatchLister(
88                 assetManager,
89                 ModelType.ANNOTATOR,
90                 ASSETS_DIR,
91                 "annotator\\.(.*)\\.model",
92                 /* isEnabled= */ () -> true),
93             // Actions models.
94             new RegularFilePatternMatchLister(
95                 ModelType.ACTIONS_SUGGESTIONS,
96                 this.modelDownloaderDir,
97                 "actions_suggestions\\.(.*)\\.model",
98                 settings::isModelDownloadManagerEnabled),
99             new RegularFileFullMatchLister(
100                 ModelType.ACTIONS_SUGGESTIONS,
101                 new File(CONFIG_UPDATER_DIR, "actions_suggestions.model"),
102                 /* isEnabled= */ () -> true),
103             new AssetFilePatternMatchLister(
104                 assetManager,
105                 ModelType.ACTIONS_SUGGESTIONS,
106                 ASSETS_DIR,
107                 "actions_suggestions\\.(.*)\\.model",
108                 /* isEnabled= */ () -> true),
109             // LangID models.
110             new RegularFilePatternMatchLister(
111                 ModelType.LANG_ID,
112                 this.modelDownloaderDir,
113                 "lang_id\\.(.*)\\.model",
114                 settings::isModelDownloadManagerEnabled),
115             new RegularFileFullMatchLister(
116                 ModelType.LANG_ID,
117                 new File(CONFIG_UPDATER_DIR, "lang_id.model"),
118                 /* isEnabled= */ () -> true),
119             new AssetFilePatternMatchLister(
120                 assetManager,
121                 ModelType.LANG_ID,
122                 ASSETS_DIR,
123                 "lang_id.model",
124                 /* isEnabled= */ () -> true));
125   }
126 
127   @VisibleForTesting
ModelFileManager(Context context, List<ModelFileLister> modelFileListers)128   public ModelFileManager(Context context, List<ModelFileLister> modelFileListers) {
129     this.modelDownloaderDir = new File(context.getFilesDir(), DOWNLOAD_SUB_DIR_NAME);
130     this.modelFileListers = ImmutableList.copyOf(modelFileListers);
131   }
132 
133   /**
134    * Returns an immutable list of model files listed by the given model files supplier.
135    *
136    * @param modelType which type of model files to look for
137    */
listModelFiles(@odelTypeDef String modelType)138   public ImmutableList<ModelFile> listModelFiles(@ModelTypeDef String modelType) {
139     Preconditions.checkNotNull(modelType);
140 
141     ImmutableList.Builder<ModelFile> modelFiles = new ImmutableList.Builder<>();
142     for (ModelFileLister modelFileLister : modelFileListers) {
143       modelFiles.addAll(modelFileLister.list(modelType));
144     }
145     return modelFiles.build();
146   }
147 
148   /** Lists model files. */
149   public interface ModelFileLister {
list(@odelTypeDef String modelType)150     List<ModelFile> list(@ModelTypeDef String modelType);
151   }
152 
153   /** Lists model files by performing full match on file path. */
154   public static class RegularFileFullMatchLister implements ModelFileLister {
155     private final String modelType;
156     private final File targetFile;
157     private final Supplier<Boolean> isEnabled;
158 
159     /**
160      * @param modelType the type of the model
161      * @param targetFile the expected model file
162      * @param isEnabled whether this lister is enabled
163      */
RegularFileFullMatchLister( @odelTypeDef String modelType, File targetFile, Supplier<Boolean> isEnabled)164     public RegularFileFullMatchLister(
165         @ModelTypeDef String modelType, File targetFile, Supplier<Boolean> isEnabled) {
166       this.modelType = Preconditions.checkNotNull(modelType);
167       this.targetFile = Preconditions.checkNotNull(targetFile);
168       this.isEnabled = Preconditions.checkNotNull(isEnabled);
169     }
170 
171     @Override
list(@odelTypeDef String modelType)172     public ImmutableList<ModelFile> list(@ModelTypeDef String modelType) {
173       if (!this.modelType.equals(modelType)) {
174         return ImmutableList.of();
175       }
176       if (!isEnabled.get()) {
177         return ImmutableList.of();
178       }
179       if (!targetFile.exists()) {
180         return ImmutableList.of();
181       }
182       try {
183         return ImmutableList.of(ModelFile.createFromRegularFile(targetFile, modelType));
184       } catch (IOException e) {
185         TcLog.e(
186             TAG, "Failed to call createFromRegularFile with: " + targetFile.getAbsolutePath(), e);
187       }
188       return ImmutableList.of();
189     }
190   }
191 
192   /** Lists model file in a specified folder by doing pattern matching on file names. */
193   public static class RegularFilePatternMatchLister implements ModelFileLister {
194     private final String modelType;
195     private final File folder;
196     private final Pattern fileNamePattern;
197     private final Supplier<Boolean> isEnabled;
198 
199     /**
200      * @param modelType the type of the model
201      * @param folder the folder to list files
202      * @param fileNameRegex the regex to match the file name in the specified folder
203      * @param isEnabled whether the lister is enabled
204      */
RegularFilePatternMatchLister( @odelTypeDef String modelType, File folder, String fileNameRegex, Supplier<Boolean> isEnabled)205     public RegularFilePatternMatchLister(
206         @ModelTypeDef String modelType,
207         File folder,
208         String fileNameRegex,
209         Supplier<Boolean> isEnabled) {
210       this.modelType = Preconditions.checkNotNull(modelType);
211       this.folder = Preconditions.checkNotNull(folder);
212       this.fileNamePattern = Pattern.compile(Preconditions.checkNotNull(fileNameRegex));
213       this.isEnabled = Preconditions.checkNotNull(isEnabled);
214     }
215 
216     @Override
list(@odelTypeDef String modelType)217     public ImmutableList<ModelFile> list(@ModelTypeDef String modelType) {
218       if (!this.modelType.equals(modelType)) {
219         return ImmutableList.of();
220       }
221       if (!isEnabled.get()) {
222         return ImmutableList.of();
223       }
224       if (!folder.isDirectory()) {
225         return ImmutableList.of();
226       }
227       File[] files = folder.listFiles();
228       if (files == null) {
229         return ImmutableList.of();
230       }
231       ImmutableList.Builder<ModelFile> modelFilesBuilder = ImmutableList.builder();
232       for (File file : files) {
233         final Matcher matcher = fileNamePattern.matcher(file.getName());
234         if (!matcher.matches() || !file.isFile()) {
235           continue;
236         }
237         try {
238           modelFilesBuilder.add(ModelFile.createFromRegularFile(file, modelType));
239         } catch (IOException e) {
240           TcLog.w(TAG, "Failed to call createFromRegularFile with: " + file.getAbsolutePath());
241         }
242       }
243       return modelFilesBuilder.build();
244     }
245   }
246 
247   /** Lists the model files preloaded in the APK file. */
248   public static class AssetFilePatternMatchLister implements ModelFileLister {
249     private final AssetManager assetManager;
250     private final String modelType;
251     private final String pathToList;
252     private final Pattern fileNamePattern;
253     private final Supplier<Boolean> isEnabled;
254     private final Object lock = new Object();
255     // Assets won't change without updating the app, so cache the result for performance reason.
256     @GuardedBy("lock")
257     private final Map<String, ImmutableList<ModelFile>> resultCache;
258 
259     /**
260      * @param modelType the type of the model.
261      * @param pathToList the folder to list files
262      * @param fileNameRegex the regex to match the file name in the specified folder
263      * @param isEnabled whether this lister is enabled
264      */
AssetFilePatternMatchLister( AssetManager assetManager, @ModelTypeDef String modelType, String pathToList, String fileNameRegex, Supplier<Boolean> isEnabled)265     public AssetFilePatternMatchLister(
266         AssetManager assetManager,
267         @ModelTypeDef String modelType,
268         String pathToList,
269         String fileNameRegex,
270         Supplier<Boolean> isEnabled) {
271       this.assetManager = Preconditions.checkNotNull(assetManager);
272       this.modelType = Preconditions.checkNotNull(modelType);
273       this.pathToList = Preconditions.checkNotNull(pathToList);
274       this.fileNamePattern = Pattern.compile(Preconditions.checkNotNull(fileNameRegex));
275       this.isEnabled = Preconditions.checkNotNull(isEnabled);
276       resultCache = new ArrayMap<>();
277     }
278 
279     @Override
list(@odelTypeDef String modelType)280     public ImmutableList<ModelFile> list(@ModelTypeDef String modelType) {
281       if (!this.modelType.equals(modelType)) {
282         return ImmutableList.of();
283       }
284       if (!isEnabled.get()) {
285         return ImmutableList.of();
286       }
287       synchronized (lock) {
288         if (resultCache.get(modelType) != null) {
289           return resultCache.get(modelType);
290         }
291         String[] fileNames = null;
292         try {
293           fileNames = assetManager.list(pathToList);
294         } catch (IOException e) {
295           TcLog.e(TAG, "Failed to list assets", e);
296         }
297         if (fileNames == null) {
298           return ImmutableList.of();
299         }
300         ImmutableList.Builder<ModelFile> modelFilesBuilder = ImmutableList.builder();
301         for (String fileName : fileNames) {
302           final Matcher matcher = fileNamePattern.matcher(fileName);
303           if (!matcher.matches()) {
304             continue;
305           }
306           String absolutePath =
307               new StringBuilder(pathToList).append('/').append(fileName).toString();
308           try {
309             modelFilesBuilder.add(ModelFile.createFromAsset(assetManager, absolutePath, modelType));
310           } catch (IOException e) {
311             TcLog.w(TAG, "Failed to call createFromAsset with: " + absolutePath);
312           }
313         }
314         ImmutableList<ModelFile> result = modelFilesBuilder.build();
315         resultCache.put(modelType, result);
316         return result;
317       }
318     }
319   }
320 
321   /**
322    * Returns the best model file for the given localelist, {@code null} if nothing is found.
323    *
324    * @param modelType the type of model to look up (e.g. annotator, lang_id, etc.)
325    * @param localePreferences an ordered list of user preferences for locales, use {@code null} if
326    *     there is no preference.
327    */
328   @Nullable
findBestModelFile( @odelTypeDef String modelType, @Nullable LocaleList localePreferences)329   public ModelFile findBestModelFile(
330       @ModelTypeDef String modelType, @Nullable LocaleList localePreferences) {
331     final String languages =
332         localePreferences == null || localePreferences.isEmpty()
333             ? LocaleList.getDefault().toLanguageTags()
334             : localePreferences.toLanguageTags();
335     final List<Locale.LanguageRange> languageRangeList = Locale.LanguageRange.parse(languages);
336 
337     ModelFile bestModel = null;
338     for (ModelFile model : listModelFiles(modelType)) {
339       // TODO(licha): update this when we want to support multiple languages
340       if (model.isAnyLanguageSupported(languageRangeList)) {
341         if (model.isPreferredTo(bestModel)) {
342           bestModel = model;
343         }
344       }
345     }
346     return bestModel;
347   }
348 
349   /**
350    * Deletes model files that are not preferred for any locales in user's preference.
351    *
352    * <p>This method will be invoked as a clean-up after we download a new model successfully. Race
353    * conditions are hard to avoid because we do not hold locks for files. But it should rarely cause
354    * any issues since it's safe to delete a model file in use (b/c we mmap it to memory).
355    */
deleteUnusedModelFiles()356   public void deleteUnusedModelFiles() {
357     TcLog.d(TAG, "Start to delete unused model files.");
358     LocaleList localeList = LocaleList.getDefault();
359     for (@ModelTypeDef String modelType : ModelType.values()) {
360       ArraySet<ModelFile> allModelFiles = new ArraySet<>(listModelFiles(modelType));
361       for (int i = 0; i < localeList.size(); i++) {
362         // If a model file is preferred for any local in locale list, then keep it
363         ModelFile bestModel = findBestModelFile(modelType, new LocaleList(localeList.get(i)));
364         allModelFiles.remove(bestModel);
365       }
366       for (ModelFile modelFile : allModelFiles) {
367         if (modelFile.canWrite()) {
368           TcLog.d(TAG, "Deleting model: " + modelFile);
369           if (!modelFile.delete()) {
370             TcLog.w(TAG, "Failed to delete model: " + modelFile);
371           }
372         }
373       }
374     }
375   }
376 
377   /** Returns the directory containing models downloaded by the downloader. */
getModelDownloaderDir()378   public File getModelDownloaderDir() {
379     return modelDownloaderDir;
380   }
381 
382   /**
383    * Dumps the internal state for debugging.
384    *
385    * @param printWriter writer to write dumped states
386    */
dump(IndentingPrintWriter printWriter)387   public void dump(IndentingPrintWriter printWriter) {
388     printWriter.println("ModelFileManager:");
389     printWriter.increaseIndent();
390     for (@ModelTypeDef String modelType : ModelType.values()) {
391       printWriter.println(modelType + " model file(s):");
392       printWriter.increaseIndent();
393       for (ModelFile modelFile : listModelFiles(modelType)) {
394         printWriter.println(modelFile.toString());
395       }
396       printWriter.decreaseIndent();
397     }
398     printWriter.decreaseIndent();
399   }
400 
401   /** Fetch metadata of a model file. */
402   private static class ModelInfoFetcher {
403     private final Function<AssetFileDescriptor, Integer> versionFetcher;
404     private final Function<AssetFileDescriptor, String> supportedLocalesFetcher;
405 
ModelInfoFetcher( Function<AssetFileDescriptor, Integer> versionFetcher, Function<AssetFileDescriptor, String> supportedLocalesFetcher)406     private ModelInfoFetcher(
407         Function<AssetFileDescriptor, Integer> versionFetcher,
408         Function<AssetFileDescriptor, String> supportedLocalesFetcher) {
409       this.versionFetcher = versionFetcher;
410       this.supportedLocalesFetcher = supportedLocalesFetcher;
411     }
412 
getVersion(AssetFileDescriptor assetFileDescriptor)413     int getVersion(AssetFileDescriptor assetFileDescriptor) {
414       return versionFetcher.apply(assetFileDescriptor);
415     }
416 
getSupportedLocales(AssetFileDescriptor assetFileDescriptor)417     String getSupportedLocales(AssetFileDescriptor assetFileDescriptor) {
418       return supportedLocalesFetcher.apply(assetFileDescriptor);
419     }
420 
create(@odelTypeDef String modelType)421     static ModelInfoFetcher create(@ModelTypeDef String modelType) {
422       switch (modelType) {
423         case ModelType.ANNOTATOR:
424           return new ModelInfoFetcher(AnnotatorModel::getVersion, AnnotatorModel::getLocales);
425         case ModelType.ACTIONS_SUGGESTIONS:
426           return new ModelInfoFetcher(
427               ActionsSuggestionsModel::getVersion, ActionsSuggestionsModel::getLocales);
428         case ModelType.LANG_ID:
429           return new ModelInfoFetcher(
430               LangIdModel::getVersion, afd -> ModelFile.LANGUAGE_INDEPENDENT);
431         default: // fall out
432       }
433       throw new IllegalStateException("Unsupported model types");
434     }
435   }
436 
437   /** Describes TextClassifier model files on disk. */
438   public static class ModelFile {
439     @VisibleForTesting static final String LANGUAGE_INDEPENDENT = "*";
440 
441     @ModelTypeDef public final String modelType;
442     public final String absolutePath;
443     public final int version;
444     public final LocaleList supportedLocales;
445     public final boolean languageIndependent;
446     public final boolean isAsset;
447 
createFromRegularFile(File file, @ModelTypeDef String modelType)448     public static ModelFile createFromRegularFile(File file, @ModelTypeDef String modelType)
449         throws IOException {
450       ParcelFileDescriptor pfd =
451           ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
452       try (AssetFileDescriptor afd = new AssetFileDescriptor(pfd, 0, file.length())) {
453         return createFromAssetFileDescriptor(
454             file.getAbsolutePath(), modelType, afd, /* isAsset= */ false);
455       }
456     }
457 
createFromAsset( AssetManager assetManager, String absolutePath, @ModelTypeDef String modelType)458     public static ModelFile createFromAsset(
459         AssetManager assetManager, String absolutePath, @ModelTypeDef String modelType)
460         throws IOException {
461       try (AssetFileDescriptor assetFileDescriptor = assetManager.openFd(absolutePath)) {
462         return createFromAssetFileDescriptor(
463             absolutePath, modelType, assetFileDescriptor, /* isAsset= */ true);
464       }
465     }
466 
createFromAssetFileDescriptor( String absolutePath, @ModelTypeDef String modelType, AssetFileDescriptor assetFileDescriptor, boolean isAsset)467     private static ModelFile createFromAssetFileDescriptor(
468         String absolutePath,
469         @ModelTypeDef String modelType,
470         AssetFileDescriptor assetFileDescriptor,
471         boolean isAsset) {
472       ModelInfoFetcher modelInfoFetcher = ModelInfoFetcher.create(modelType);
473       return new ModelFile(
474           modelType,
475           absolutePath,
476           modelInfoFetcher.getVersion(assetFileDescriptor),
477           modelInfoFetcher.getSupportedLocales(assetFileDescriptor),
478           isAsset);
479     }
480 
481     @VisibleForTesting
ModelFile( @odelTypeDef String modelType, String absolutePath, int version, String supportedLocaleTags, boolean isAsset)482     ModelFile(
483         @ModelTypeDef String modelType,
484         String absolutePath,
485         int version,
486         String supportedLocaleTags,
487         boolean isAsset) {
488       this.modelType = modelType;
489       this.absolutePath = absolutePath;
490       this.version = version;
491       this.languageIndependent = LANGUAGE_INDEPENDENT.equals(supportedLocaleTags);
492       this.supportedLocales =
493           languageIndependent
494               ? LocaleList.getEmptyLocaleList()
495               : LocaleList.forLanguageTags(supportedLocaleTags);
496       this.isAsset = isAsset;
497     }
498 
499     /** Returns if this model file is preferred to the given one. */
isPreferredTo(@ullable ModelFile model)500     public boolean isPreferredTo(@Nullable ModelFile model) {
501       // A model is preferred to no model.
502       if (model == null) {
503         return true;
504       }
505 
506       // A language-specific model is preferred to a language independent
507       // model.
508       if (!languageIndependent && model.languageIndependent) {
509         return true;
510       }
511       if (languageIndependent && !model.languageIndependent) {
512         return false;
513       }
514 
515       // A higher-version model is preferred.
516       if (version > model.version) {
517         return true;
518       }
519       return false;
520     }
521 
522     /** Returns whether the language supports any language in the given ranges. */
isAnyLanguageSupported(List<Locale.LanguageRange> languageRanges)523     public boolean isAnyLanguageSupported(List<Locale.LanguageRange> languageRanges) {
524       Preconditions.checkNotNull(languageRanges);
525       if (languageIndependent) {
526         return true;
527       }
528       List<String> supportedLocaleTags =
529           Arrays.asList(supportedLocales.toLanguageTags().split(","));
530       return Locale.lookupTag(languageRanges, supportedLocaleTags) != null;
531     }
532 
open(AssetManager assetManager)533     public AssetFileDescriptor open(AssetManager assetManager) throws IOException {
534       if (isAsset) {
535         return assetManager.openFd(absolutePath);
536       }
537       File file = new File(absolutePath);
538       ParcelFileDescriptor parcelFileDescriptor =
539           ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
540       return new AssetFileDescriptor(parcelFileDescriptor, 0, file.length());
541     }
542 
canWrite()543     public boolean canWrite() {
544       if (isAsset) {
545         return false;
546       }
547       return new File(absolutePath).canWrite();
548     }
549 
delete()550     public boolean delete() {
551       if (isAsset) {
552         throw new IllegalStateException("asset is read-only, deleting it is not allowed.");
553       }
554       return new File(absolutePath).delete();
555     }
556 
557     @Override
equals(Object o)558     public boolean equals(Object o) {
559       if (this == o) {
560         return true;
561       }
562       if (!(o instanceof ModelFile)) {
563         return false;
564       }
565       ModelFile modelFile = (ModelFile) o;
566       return version == modelFile.version
567           && languageIndependent == modelFile.languageIndependent
568           && isAsset == modelFile.isAsset
569           && Objects.equals(modelType, modelFile.modelType)
570           && Objects.equals(absolutePath, modelFile.absolutePath)
571           && Objects.equals(supportedLocales, modelFile.supportedLocales);
572     }
573 
574     @Override
hashCode()575     public int hashCode() {
576       return Objects.hash(
577           modelType, absolutePath, version, supportedLocales, languageIndependent, isAsset);
578     }
579 
toModelInfo()580     public ModelInfo toModelInfo() {
581       return new ModelInfo(version, supportedLocales.toLanguageTags());
582     }
583 
584     @Override
toString()585     public String toString() {
586       return String.format(
587           Locale.US,
588           "ModelFile { type=%s path=%s version=%d locales=%s isAsset=%b}",
589           modelType,
590           absolutePath,
591           version,
592           languageIndependent ? LANGUAGE_INDEPENDENT : supportedLocales.toLanguageTags(),
593           isAsset);
594     }
595 
toModelInfos( Optional<ModelFileManager.ModelFile>.... modelFiles)596     public static ImmutableList<Optional<ModelInfo>> toModelInfos(
597         Optional<ModelFileManager.ModelFile>... modelFiles) {
598       return Arrays.stream(modelFiles)
599           .map(modelFile -> modelFile.transform(ModelFileManager.ModelFile::toModelInfo))
600           .collect(Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf));
601     }
602   }
603 }
604