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