1 /**
2  * Copyright (C) 2014 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 android.hardware.soundtrigger;
18 
19 import android.Manifest;
20 import android.annotation.IntDef;
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.annotation.TestApi;
24 import android.content.Intent;
25 import android.content.pm.ApplicationInfo;
26 import android.content.pm.PackageManager;
27 import android.content.pm.ResolveInfo;
28 import android.content.res.Resources;
29 import android.content.res.TypedArray;
30 import android.content.res.XmlResourceParser;
31 import android.text.TextUtils;
32 import android.util.ArraySet;
33 import android.util.AttributeSet;
34 import android.util.Slog;
35 import android.util.Xml;
36 
37 import org.xmlpull.v1.XmlPullParser;
38 import org.xmlpull.v1.XmlPullParserException;
39 
40 import java.io.IOException;
41 import java.lang.annotation.Retention;
42 import java.lang.annotation.RetentionPolicy;
43 import java.util.Arrays;
44 import java.util.Collection;
45 import java.util.Collections;
46 import java.util.HashMap;
47 import java.util.LinkedList;
48 import java.util.List;
49 import java.util.Locale;
50 import java.util.Map;
51 import java.util.Objects;
52 
53 /**
54  * Enrollment information about the different available keyphrases.
55  *
56  * @hide
57  */
58 @TestApi
59 public class KeyphraseEnrollmentInfo {
60     private static final String TAG = "KeyphraseEnrollmentInfo";
61     /**
62      * Name under which a Hotword enrollment component publishes information about itself.
63      * This meta-data should reference an XML resource containing a
64      * <code>&lt;{@link
65      * android.R.styleable#VoiceEnrollmentApplication
66      * voice-enrollment-application}&gt;</code> tag.
67      */
68     private static final String VOICE_KEYPHRASE_META_DATA = "android.voice_enrollment";
69     /**
70      * Intent Action: for managing the keyphrases for hotword detection.
71      * This needs to be defined by a service that supports enrolling users for hotword/keyphrase
72      * detection.
73      * @hide
74      */
75     public static final String ACTION_MANAGE_VOICE_KEYPHRASES =
76             "com.android.intent.action.MANAGE_VOICE_KEYPHRASES";
77     /**
78      * Intent extra: The intent extra for the specific manage action that needs to be performed.
79      *
80      * @see #MANAGE_ACTION_ENROLL
81      * @see #MANAGE_ACTION_RE_ENROLL
82      * @see #MANAGE_ACTION_UN_ENROLL
83      * @hide
84      */
85     public static final String EXTRA_VOICE_KEYPHRASE_ACTION =
86             "com.android.intent.extra.VOICE_KEYPHRASE_ACTION";
87 
88     /**
89      * Intent extra: The hint text to be shown on the voice keyphrase management UI.
90      * @hide
91      */
92     public static final String EXTRA_VOICE_KEYPHRASE_HINT_TEXT =
93             "com.android.intent.extra.VOICE_KEYPHRASE_HINT_TEXT";
94     /**
95      * Intent extra: The voice locale to use while managing the keyphrase.
96      * This is a BCP-47 language tag.
97      * @hide
98      */
99     public static final String EXTRA_VOICE_KEYPHRASE_LOCALE =
100             "com.android.intent.extra.VOICE_KEYPHRASE_LOCALE";
101 
102     /**
103      * Keyphrase management actions used with the {@link #EXTRA_VOICE_KEYPHRASE_ACTION} intent extra
104      * @hide
105      */
106     @Retention(RetentionPolicy.SOURCE)
107     @IntDef(prefix = { "MANAGE_ACTION_" }, value = {
108             MANAGE_ACTION_ENROLL,
109             MANAGE_ACTION_RE_ENROLL,
110             MANAGE_ACTION_UN_ENROLL
111     })
112     public @interface ManageActions {}
113 
114     /**
115      * Indicates desired action to enroll keyphrase model
116      */
117     public static final int MANAGE_ACTION_ENROLL = 0;
118     /**
119      * Indicates desired action to re-enroll keyphrase model
120      */
121     public static final int MANAGE_ACTION_RE_ENROLL = 1;
122     /**
123      * Indicates desired action to un-enroll keyphrase model
124      */
125     public static final int MANAGE_ACTION_UN_ENROLL = 2;
126 
127     /**
128      * List of available keyphrases.
129      */
130     private final KeyphraseMetadata[] mKeyphrases;
131 
132     /**
133      * Map between KeyphraseMetadata and the package name of the enrollment app that provides it.
134      */
135     final private Map<KeyphraseMetadata, String> mKeyphrasePackageMap;
136 
137     private String mParseError;
138 
KeyphraseEnrollmentInfo(@onNull PackageManager pm)139     public KeyphraseEnrollmentInfo(@NonNull PackageManager pm) {
140         Objects.requireNonNull(pm);
141         // Find the apps that supports enrollment for hotword keyhphrases,
142         // Pick a privileged app and obtain the information about the supported keyphrases
143         // from its metadata.
144         List<ResolveInfo> ris = pm.queryIntentServices(
145                 new Intent(ACTION_MANAGE_VOICE_KEYPHRASES), PackageManager.MATCH_DEFAULT_ONLY);
146         if (ris == null || ris.isEmpty()) {
147             // No application capable of enrolling for voice keyphrases is present.
148             mParseError = "No enrollment applications found";
149             mKeyphrasePackageMap = Collections.emptyMap();
150             mKeyphrases = null;
151             return;
152         }
153 
154         List<String> parseErrors = new LinkedList<>();
155         mKeyphrasePackageMap = new HashMap<>();
156         for (ResolveInfo ri : ris) {
157             try {
158                 ApplicationInfo ai = pm.getApplicationInfo(
159                         ri.serviceInfo.packageName, PackageManager.GET_META_DATA);
160                 if ((ai.privateFlags & ApplicationInfo.PRIVATE_FLAG_PRIVILEGED) == 0) {
161                     // The application isn't privileged (/system/priv-app).
162                     // The enrollment application needs to be a privileged system app.
163                     Slog.w(TAG, ai.packageName + " is not a privileged system app");
164                     continue;
165                 }
166                 if (!Manifest.permission.MANAGE_VOICE_KEYPHRASES.equals(ai.permission)) {
167                     // The application trying to manage keyphrases doesn't
168                     // require the MANAGE_VOICE_KEYPHRASES permission.
169                     Slog.w(TAG, ai.packageName + " does not require MANAGE_VOICE_KEYPHRASES");
170                     continue;
171                 }
172 
173                 KeyphraseMetadata metadata =
174                         getKeyphraseMetadataFromApplicationInfo(pm, ai, parseErrors);
175                 if (metadata != null) {
176                     mKeyphrasePackageMap.put(metadata, ai.packageName);
177                 }
178             } catch (PackageManager.NameNotFoundException e) {
179                 String error = "error parsing voice enrollment meta-data for "
180                         + ri.serviceInfo.packageName;
181                 parseErrors.add(error + ": " + e);
182                 Slog.w(TAG, error, e);
183             }
184         }
185 
186         if (mKeyphrasePackageMap.isEmpty()) {
187             String error = "No suitable enrollment application found";
188             parseErrors.add(error);
189             Slog.w(TAG, error);
190             mKeyphrases = null;
191         } else {
192             mKeyphrases = mKeyphrasePackageMap.keySet().toArray(
193                     new KeyphraseMetadata[0]);
194         }
195 
196         if (!parseErrors.isEmpty()) {
197             mParseError = TextUtils.join("\n", parseErrors);
198         }
199     }
200 
getKeyphraseMetadataFromApplicationInfo(PackageManager pm, ApplicationInfo ai, List<String> parseErrors)201     private KeyphraseMetadata getKeyphraseMetadataFromApplicationInfo(PackageManager pm,
202             ApplicationInfo ai, List<String> parseErrors) {
203         XmlResourceParser parser = null;
204         String packageName = ai.packageName;
205         KeyphraseMetadata keyphraseMetadata = null;
206         try {
207             parser = ai.loadXmlMetaData(pm, VOICE_KEYPHRASE_META_DATA);
208             if (parser == null) {
209                 String error = "No " + VOICE_KEYPHRASE_META_DATA + " meta-data for " + packageName;
210                 parseErrors.add(error);
211                 Slog.w(TAG, error);
212                 return null;
213             }
214 
215             Resources res = pm.getResourcesForApplication(ai);
216             AttributeSet attrs = Xml.asAttributeSet(parser);
217 
218             int type;
219             while ((type=parser.next()) != XmlPullParser.END_DOCUMENT
220                     && type != XmlPullParser.START_TAG) {
221             }
222 
223             String nodeName = parser.getName();
224             if (!"voice-enrollment-application".equals(nodeName)) {
225                 String error = "Meta-data does not start with voice-enrollment-application tag for "
226                         + packageName;
227                 parseErrors.add(error);
228                 Slog.w(TAG, error);
229                 return null;
230             }
231 
232             TypedArray array = res.obtainAttributes(attrs,
233                     com.android.internal.R.styleable.VoiceEnrollmentApplication);
234             keyphraseMetadata = getKeyphraseFromTypedArray(array, packageName, parseErrors);
235             array.recycle();
236         } catch (XmlPullParserException | PackageManager.NameNotFoundException | IOException e) {
237             String error = "Error parsing keyphrase enrollment meta-data for " + packageName;
238             parseErrors.add(error + ": " + e);
239             Slog.w(TAG, error, e);
240         } finally {
241             if (parser != null) parser.close();
242         }
243         return keyphraseMetadata;
244     }
245 
getKeyphraseFromTypedArray(TypedArray array, String packageName, List<String> parseErrors)246     private KeyphraseMetadata getKeyphraseFromTypedArray(TypedArray array, String packageName,
247             List<String> parseErrors) {
248         // Get the keyphrase ID.
249         int searchKeyphraseId = array.getInt(
250                 com.android.internal.R.styleable.VoiceEnrollmentApplication_searchKeyphraseId, -1);
251         if (searchKeyphraseId <= 0) {
252             String error = "No valid searchKeyphraseId specified in meta-data for " + packageName;
253             parseErrors.add(error);
254             Slog.w(TAG, error);
255             return null;
256         }
257 
258         // Get the keyphrase text.
259         String searchKeyphrase = array.getString(
260                 com.android.internal.R.styleable.VoiceEnrollmentApplication_searchKeyphrase);
261         if (searchKeyphrase == null) {
262             String error = "No valid searchKeyphrase specified in meta-data for " + packageName;
263             parseErrors.add(error);
264             Slog.w(TAG, error);
265             return null;
266         }
267 
268         // Get the supported locales.
269         String searchKeyphraseSupportedLocales = array.getString(
270                 com.android.internal.R.styleable
271                         .VoiceEnrollmentApplication_searchKeyphraseSupportedLocales);
272         if (searchKeyphraseSupportedLocales == null) {
273             String error = "No valid searchKeyphraseSupportedLocales specified in meta-data for "
274                     + packageName;
275             parseErrors.add(error);
276             Slog.w(TAG, error);
277             return null;
278         }
279         ArraySet<Locale> locales = new ArraySet<>();
280         // Try adding locales if the locale string is non-empty.
281         if (!TextUtils.isEmpty(searchKeyphraseSupportedLocales)) {
282             try {
283                 String[] supportedLocalesDelimited = searchKeyphraseSupportedLocales.split(",");
284                 for (String s : supportedLocalesDelimited) {
285                     locales.add(Locale.forLanguageTag(s));
286                 }
287             } catch (Exception ex) {
288                 // We catch a generic exception here because we don't want the system service
289                 // to be affected by a malformed metadata because invalid locales were specified
290                 // by the system application.
291                 String error = "Error reading searchKeyphraseSupportedLocales from meta-data for "
292                         + packageName;
293                 parseErrors.add(error);
294                 Slog.w(TAG, error);
295                 return null;
296             }
297         }
298 
299         // Get the supported recognition modes.
300         int recognitionModes = array.getInt(com.android.internal.R.styleable
301                 .VoiceEnrollmentApplication_searchKeyphraseRecognitionFlags, -1);
302         if (recognitionModes < 0) {
303             String error = "No valid searchKeyphraseRecognitionFlags specified in meta-data for "
304                     + packageName;
305             parseErrors.add(error);
306             Slog.w(TAG, error);
307             return null;
308         }
309         return new KeyphraseMetadata(searchKeyphraseId, searchKeyphrase, locales, recognitionModes);
310     }
311 
312     @NonNull
getParseError()313     public String getParseError() {
314         return mParseError;
315     }
316 
317     /**
318      * @return An array of available keyphrases that can be enrolled on the system.
319      *         It may be null if no keyphrases can be enrolled.
320      */
321     @NonNull
listKeyphraseMetadata()322     public Collection<KeyphraseMetadata> listKeyphraseMetadata() {
323         return Arrays.asList(mKeyphrases);
324     }
325 
326     /**
327      * Returns an intent to launch an service that manages the given keyphrase
328      * for the locale.
329      *
330      * @param action The enrollment related action that this intent is supposed to perform.
331      * @param keyphrase The keyphrase that the user needs to be enrolled to.
332      * @param locale The locale for which the enrollment needs to be performed.
333      * @return An {@link Intent} to manage the keyphrase. This can be null if managing the
334      *         given keyphrase/locale combination isn't possible.
335      */
336     @Nullable
getManageKeyphraseIntent(@anageActions int action, @NonNull String keyphrase, @NonNull Locale locale)337     public Intent getManageKeyphraseIntent(@ManageActions int action, @NonNull String keyphrase,
338             @NonNull Locale locale) {
339         Objects.requireNonNull(keyphrase);
340         Objects.requireNonNull(locale);
341         if (mKeyphrasePackageMap == null || mKeyphrasePackageMap.isEmpty()) {
342             Slog.w(TAG, "No enrollment application exists");
343             return null;
344         }
345 
346         KeyphraseMetadata keyphraseMetadata = getKeyphraseMetadata(keyphrase, locale);
347         if (keyphraseMetadata != null) {
348             return new Intent(ACTION_MANAGE_VOICE_KEYPHRASES)
349                     .setPackage(mKeyphrasePackageMap.get(keyphraseMetadata))
350                     .putExtra(EXTRA_VOICE_KEYPHRASE_HINT_TEXT, keyphrase)
351                     .putExtra(EXTRA_VOICE_KEYPHRASE_LOCALE, locale.toLanguageTag())
352                     .putExtra(EXTRA_VOICE_KEYPHRASE_ACTION, action);
353         }
354         return null;
355     }
356 
357     /**
358      * Gets the {@link KeyphraseMetadata} for the given keyphrase and locale, null if any metadata
359      * isn't available for the given combination.
360      *
361      * @param keyphrase The keyphrase that the user needs to be enrolled to.
362      * @param locale The locale for which the enrollment needs to be performed.
363      *        This is a Java locale, for example "en_US".
364      * @return The metadata, if the enrollment client supports the given keyphrase
365      *         and locale, null otherwise.
366      */
367     @Nullable
getKeyphraseMetadata(@onNull String keyphrase, @NonNull Locale locale)368     public KeyphraseMetadata getKeyphraseMetadata(@NonNull String keyphrase,
369             @NonNull Locale locale) {
370         Objects.requireNonNull(keyphrase);
371         Objects.requireNonNull(locale);
372         if (mKeyphrases != null && mKeyphrases.length > 0) {
373           for (KeyphraseMetadata keyphraseMetadata : mKeyphrases) {
374               // Check if the given keyphrase is supported in the locale provided by
375               // the enrollment application.
376               if (keyphraseMetadata.supportsPhrase(keyphrase)
377                       && keyphraseMetadata.supportsLocale(locale)) {
378                   return keyphraseMetadata;
379               }
380           }
381         }
382         Slog.w(TAG, "No enrollment application supports the given keyphrase/locale: '"
383                 + keyphrase + "'/" + locale);
384         return null;
385     }
386 
387     @Override
toString()388     public String toString() {
389         return "KeyphraseEnrollmentInfo [KeyphrasePackageMap=" + mKeyphrasePackageMap.toString()
390                 + ", ParseError=" + mParseError + "]";
391     }
392 }
393