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 package android.view.textclassifier;
17 
18 import static android.view.textclassifier.TextClassifier.DEFAULT_LOG_TAG;
19 
20 import android.annotation.Nullable;
21 import android.os.LocaleList;
22 import android.os.ParcelFileDescriptor;
23 import android.text.TextUtils;
24 
25 import com.android.internal.annotations.VisibleForTesting;
26 import com.android.internal.util.Preconditions;
27 
28 import java.io.File;
29 import java.io.FileNotFoundException;
30 import java.io.IOException;
31 import java.util.ArrayList;
32 import java.util.Collections;
33 import java.util.List;
34 import java.util.Locale;
35 import java.util.Objects;
36 import java.util.StringJoiner;
37 import java.util.function.Function;
38 import java.util.function.Supplier;
39 import java.util.regex.Matcher;
40 import java.util.regex.Pattern;
41 
42 /**
43  * Manages model files that are listed by the model files supplier.
44  * @hide
45  */
46 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
47 public final class ModelFileManager {
48     private final Object mLock = new Object();
49     private final Supplier<List<ModelFile>> mModelFileSupplier;
50 
51     private List<ModelFile> mModelFiles;
52 
ModelFileManager(Supplier<List<ModelFile>> modelFileSupplier)53     public ModelFileManager(Supplier<List<ModelFile>> modelFileSupplier) {
54         mModelFileSupplier = Preconditions.checkNotNull(modelFileSupplier);
55     }
56 
57     /**
58      * Returns an unmodifiable list of model files listed by the given model files supplier.
59      * <p>
60      * The result is cached.
61      */
listModelFiles()62     public List<ModelFile> listModelFiles() {
63         synchronized (mLock) {
64             if (mModelFiles == null) {
65                 mModelFiles = Collections.unmodifiableList(mModelFileSupplier.get());
66             }
67             return mModelFiles;
68         }
69     }
70 
71     /**
72      * Returns the best model file for the given localelist, {@code null} if nothing is found.
73      *
74      * @param localeList the required locales, use {@code null} if there is no preference.
75      */
findBestModelFile(@ullable LocaleList localeList)76     public ModelFile findBestModelFile(@Nullable LocaleList localeList) {
77         final String languages = localeList == null || localeList.isEmpty()
78                 ? LocaleList.getDefault().toLanguageTags()
79                 : localeList.toLanguageTags();
80         final List<Locale.LanguageRange> languageRangeList = Locale.LanguageRange.parse(languages);
81 
82         ModelFile bestModel = null;
83         for (ModelFile model : listModelFiles()) {
84             if (model.isAnyLanguageSupported(languageRangeList)) {
85                 if (model.isPreferredTo(bestModel)) {
86                     bestModel = model;
87                 }
88             }
89         }
90         return bestModel;
91     }
92 
93     /**
94      * Default implementation of the model file supplier.
95      */
96     public static final class ModelFileSupplierImpl implements Supplier<List<ModelFile>> {
97         private final File mUpdatedModelFile;
98         private final File mFactoryModelDir;
99         private final Pattern mModelFilenamePattern;
100         private final Function<Integer, Integer> mVersionSupplier;
101         private final Function<Integer, String> mSupportedLocalesSupplier;
102 
ModelFileSupplierImpl( File factoryModelDir, String factoryModelFileNameRegex, File updatedModelFile, Function<Integer, Integer> versionSupplier, Function<Integer, String> supportedLocalesSupplier)103         public ModelFileSupplierImpl(
104                 File factoryModelDir,
105                 String factoryModelFileNameRegex,
106                 File updatedModelFile,
107                 Function<Integer, Integer> versionSupplier,
108                 Function<Integer, String> supportedLocalesSupplier) {
109             mUpdatedModelFile = Preconditions.checkNotNull(updatedModelFile);
110             mFactoryModelDir = Preconditions.checkNotNull(factoryModelDir);
111             mModelFilenamePattern = Pattern.compile(
112                     Preconditions.checkNotNull(factoryModelFileNameRegex));
113             mVersionSupplier = Preconditions.checkNotNull(versionSupplier);
114             mSupportedLocalesSupplier = Preconditions.checkNotNull(supportedLocalesSupplier);
115         }
116 
117         @Override
get()118         public List<ModelFile> get() {
119             final List<ModelFile> modelFiles = new ArrayList<>();
120             // The update model has the highest precedence.
121             if (mUpdatedModelFile.exists()) {
122                 final ModelFile updatedModel = createModelFile(mUpdatedModelFile);
123                 if (updatedModel != null) {
124                     modelFiles.add(updatedModel);
125                 }
126             }
127             // Factory models should never have overlapping locales, so the order doesn't matter.
128             if (mFactoryModelDir.exists() && mFactoryModelDir.isDirectory()) {
129                 final File[] files = mFactoryModelDir.listFiles();
130                 for (File file : files) {
131                     final Matcher matcher = mModelFilenamePattern.matcher(file.getName());
132                     if (matcher.matches() && file.isFile()) {
133                         final ModelFile model = createModelFile(file);
134                         if (model != null) {
135                             modelFiles.add(model);
136                         }
137                     }
138                 }
139             }
140             return modelFiles;
141         }
142 
143         /** Returns null if the path did not point to a compatible model. */
144         @Nullable
createModelFile(File file)145         private ModelFile createModelFile(File file) {
146             if (!file.exists()) {
147                 return null;
148             }
149             ParcelFileDescriptor modelFd = null;
150             try {
151                 modelFd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
152                 if (modelFd == null) {
153                     return null;
154                 }
155                 final int modelFdInt = modelFd.getFd();
156                 final int version = mVersionSupplier.apply(modelFdInt);
157                 final String supportedLocalesStr = mSupportedLocalesSupplier.apply(modelFdInt);
158                 if (supportedLocalesStr.isEmpty()) {
159                     Log.d(DEFAULT_LOG_TAG, "Ignoring " + file.getAbsolutePath());
160                     return null;
161                 }
162                 final List<Locale> supportedLocales = new ArrayList<>();
163                 for (String langTag : supportedLocalesStr.split(",")) {
164                     supportedLocales.add(Locale.forLanguageTag(langTag));
165                 }
166                 return new ModelFile(
167                         file,
168                         version,
169                         supportedLocales,
170                         supportedLocalesStr,
171                         ModelFile.LANGUAGE_INDEPENDENT.equals(supportedLocalesStr));
172             } catch (FileNotFoundException e) {
173                 Log.e(DEFAULT_LOG_TAG, "Failed to find " + file.getAbsolutePath(), e);
174                 return null;
175             } finally {
176                 maybeCloseAndLogError(modelFd);
177             }
178         }
179 
180         /**
181          * Closes the ParcelFileDescriptor, if non-null, and logs any errors that occur.
182          */
maybeCloseAndLogError(@ullable ParcelFileDescriptor fd)183         private static void maybeCloseAndLogError(@Nullable ParcelFileDescriptor fd) {
184             if (fd == null) {
185                 return;
186             }
187             try {
188                 fd.close();
189             } catch (IOException e) {
190                 Log.e(DEFAULT_LOG_TAG, "Error closing file.", e);
191             }
192         }
193 
194     }
195 
196     /**
197      * Describes TextClassifier model files on disk.
198      */
199     public static final class ModelFile {
200         public static final String LANGUAGE_INDEPENDENT = "*";
201 
202         private final File mFile;
203         private final int mVersion;
204         private final List<Locale> mSupportedLocales;
205         private final String mSupportedLocalesStr;
206         private final boolean mLanguageIndependent;
207 
ModelFile(File file, int version, List<Locale> supportedLocales, String supportedLocalesStr, boolean languageIndependent)208         public ModelFile(File file, int version, List<Locale> supportedLocales,
209                 String supportedLocalesStr,
210                 boolean languageIndependent) {
211             mFile = Preconditions.checkNotNull(file);
212             mVersion = version;
213             mSupportedLocales = Preconditions.checkNotNull(supportedLocales);
214             mSupportedLocalesStr = Preconditions.checkNotNull(supportedLocalesStr);
215             mLanguageIndependent = languageIndependent;
216         }
217 
218         /** Returns the absolute path to the model file. */
getPath()219         public String getPath() {
220             return mFile.getAbsolutePath();
221         }
222 
223         /** Returns a name to use for id generation, effectively the name of the model file. */
getName()224         public String getName() {
225             return mFile.getName();
226         }
227 
228         /** Returns the version tag in the model's metadata. */
getVersion()229         public int getVersion() {
230             return mVersion;
231         }
232 
233         /** Returns whether the language supports any language in the given ranges. */
isAnyLanguageSupported(List<Locale.LanguageRange> languageRanges)234         public boolean isAnyLanguageSupported(List<Locale.LanguageRange> languageRanges) {
235             Preconditions.checkNotNull(languageRanges);
236             return mLanguageIndependent || Locale.lookup(languageRanges, mSupportedLocales) != null;
237         }
238 
239         /** Returns an immutable lists of supported locales. */
getSupportedLocales()240         public List<Locale> getSupportedLocales() {
241             return Collections.unmodifiableList(mSupportedLocales);
242         }
243 
244         /** Returns the original supported locals string read from the model file. */
getSupportedLocalesStr()245         public String getSupportedLocalesStr() {
246             return mSupportedLocalesStr;
247         }
248 
249         /**
250          * Returns if this model file is preferred to the given one.
251          */
isPreferredTo(@ullable ModelFile model)252         public boolean isPreferredTo(@Nullable ModelFile model) {
253             // A model is preferred to no model.
254             if (model == null) {
255                 return true;
256             }
257 
258             // A language-specific model is preferred to a language independent
259             // model.
260             if (!mLanguageIndependent && model.mLanguageIndependent) {
261                 return true;
262             }
263             if (mLanguageIndependent && !model.mLanguageIndependent) {
264                 return false;
265             }
266 
267             // A higher-version model is preferred.
268             if (mVersion > model.getVersion()) {
269                 return true;
270             }
271             return false;
272         }
273 
274         @Override
hashCode()275         public int hashCode() {
276             return Objects.hash(getPath());
277         }
278 
279         @Override
equals(Object other)280         public boolean equals(Object other) {
281             if (this == other) {
282                 return true;
283             }
284             if (other instanceof ModelFile) {
285                 final ModelFile otherModel = (ModelFile) other;
286                 return TextUtils.equals(getPath(), otherModel.getPath());
287             }
288             return false;
289         }
290 
291         @Override
toString()292         public String toString() {
293             final StringJoiner localesJoiner = new StringJoiner(",");
294             for (Locale locale : mSupportedLocales) {
295                 localesJoiner.add(locale.toLanguageTag());
296             }
297             return String.format(Locale.US,
298                     "ModelFile { path=%s name=%s version=%d locales=%s }",
299                     getPath(), getName(), mVersion, localesJoiner.toString());
300         }
301     }
302 }
303