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