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