/**
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.hardware.soundtrigger;
import android.Manifest;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.content.res.XmlResourceParser;
import android.service.voice.AlwaysOnHotwordDetector;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.AttributeSet;
import android.util.Slog;
import android.util.Xml;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Enrollment information about the different available keyphrases.
*
* @hide
*/
public class KeyphraseEnrollmentInfo {
private static final String TAG = "KeyphraseEnrollmentInfo";
/**
* Name under which a Hotword enrollment component publishes information about itself.
* This meta-data should reference an XML resource containing a
* <{@link
* android.R.styleable#VoiceEnrollmentApplication
* voice-enrollment-application}>
tag.
*/
private static final String VOICE_KEYPHRASE_META_DATA = "android.voice_enrollment";
/**
* Activity Action: Show activity for managing the keyphrases for hotword detection.
* This needs to be defined by an activity that supports enrolling users for hotword/keyphrase
* detection.
*/
public static final String ACTION_MANAGE_VOICE_KEYPHRASES =
"com.android.intent.action.MANAGE_VOICE_KEYPHRASES";
/**
* Intent extra: The intent extra for the specific manage action that needs to be performed.
* Possible values are {@link AlwaysOnHotwordDetector#MANAGE_ACTION_ENROLL},
* {@link AlwaysOnHotwordDetector#MANAGE_ACTION_RE_ENROLL}
* or {@link AlwaysOnHotwordDetector#MANAGE_ACTION_UN_ENROLL}.
*/
public static final String EXTRA_VOICE_KEYPHRASE_ACTION =
"com.android.intent.extra.VOICE_KEYPHRASE_ACTION";
/**
* Intent extra: The hint text to be shown on the voice keyphrase management UI.
*/
public static final String EXTRA_VOICE_KEYPHRASE_HINT_TEXT =
"com.android.intent.extra.VOICE_KEYPHRASE_HINT_TEXT";
/**
* Intent extra: The voice locale to use while managing the keyphrase.
* This is a BCP-47 language tag.
*/
public static final String EXTRA_VOICE_KEYPHRASE_LOCALE =
"com.android.intent.extra.VOICE_KEYPHRASE_LOCALE";
/**
* List of available keyphrases.
*/
final private KeyphraseMetadata[] mKeyphrases;
/**
* Map between KeyphraseMetadata and the package name of the enrollment app that provides it.
*/
final private Map mKeyphrasePackageMap;
private String mParseError;
public KeyphraseEnrollmentInfo(PackageManager pm) {
// Find the apps that supports enrollment for hotword keyhphrases,
// Pick a privileged app and obtain the information about the supported keyphrases
// from its metadata.
List ris = pm.queryIntentActivities(
new Intent(ACTION_MANAGE_VOICE_KEYPHRASES), PackageManager.MATCH_DEFAULT_ONLY);
if (ris == null || ris.isEmpty()) {
// No application capable of enrolling for voice keyphrases is present.
mParseError = "No enrollment applications found";
mKeyphrasePackageMap = Collections.emptyMap();
mKeyphrases = null;
return;
}
List parseErrors = new LinkedList();
mKeyphrasePackageMap = new HashMap();
for (ResolveInfo ri : ris) {
try {
ApplicationInfo ai = pm.getApplicationInfo(
ri.activityInfo.packageName, PackageManager.GET_META_DATA);
if ((ai.privateFlags & ApplicationInfo.PRIVATE_FLAG_PRIVILEGED) == 0) {
// The application isn't privileged (/system/priv-app).
// The enrollment application needs to be a privileged system app.
Slog.w(TAG, ai.packageName + "is not a privileged system app");
continue;
}
if (!Manifest.permission.MANAGE_VOICE_KEYPHRASES.equals(ai.permission)) {
// The application trying to manage keyphrases doesn't
// require the MANAGE_VOICE_KEYPHRASES permission.
Slog.w(TAG, ai.packageName + " does not require MANAGE_VOICE_KEYPHRASES");
continue;
}
KeyphraseMetadata metadata =
getKeyphraseMetadataFromApplicationInfo(pm, ai, parseErrors);
if (metadata != null) {
mKeyphrasePackageMap.put(metadata, ai.packageName);
}
} catch (PackageManager.NameNotFoundException e) {
String error = "error parsing voice enrollment meta-data for "
+ ri.activityInfo.packageName;
parseErrors.add(error + ": " + e);
Slog.w(TAG, error, e);
}
}
if (mKeyphrasePackageMap.isEmpty()) {
String error = "No suitable enrollment application found";
parseErrors.add(error);
Slog.w(TAG, error);
mKeyphrases = null;
} else {
mKeyphrases = mKeyphrasePackageMap.keySet().toArray(
new KeyphraseMetadata[mKeyphrasePackageMap.size()]);
}
if (!parseErrors.isEmpty()) {
mParseError = TextUtils.join("\n", parseErrors);
}
}
private KeyphraseMetadata getKeyphraseMetadataFromApplicationInfo(PackageManager pm,
ApplicationInfo ai, List parseErrors) {
XmlResourceParser parser = null;
String packageName = ai.packageName;
KeyphraseMetadata keyphraseMetadata = null;
try {
parser = ai.loadXmlMetaData(pm, VOICE_KEYPHRASE_META_DATA);
if (parser == null) {
String error = "No " + VOICE_KEYPHRASE_META_DATA + " meta-data for " + packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
Resources res = pm.getResourcesForApplication(ai);
AttributeSet attrs = Xml.asAttributeSet(parser);
int type;
while ((type=parser.next()) != XmlPullParser.END_DOCUMENT
&& type != XmlPullParser.START_TAG) {
}
String nodeName = parser.getName();
if (!"voice-enrollment-application".equals(nodeName)) {
String error = "Meta-data does not start with voice-enrollment-application tag for "
+ packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
TypedArray array = res.obtainAttributes(attrs,
com.android.internal.R.styleable.VoiceEnrollmentApplication);
keyphraseMetadata = getKeyphraseFromTypedArray(array, packageName, parseErrors);
array.recycle();
} catch (XmlPullParserException e) {
String error = "Error parsing keyphrase enrollment meta-data for " + packageName;
parseErrors.add(error + ": " + e);
Slog.w(TAG, error, e);
} catch (IOException e) {
String error = "Error parsing keyphrase enrollment meta-data for " + packageName;
parseErrors.add(error + ": " + e);
Slog.w(TAG, error, e);
} catch (PackageManager.NameNotFoundException e) {
String error = "Error parsing keyphrase enrollment meta-data for " + packageName;
parseErrors.add(error + ": " + e);
Slog.w(TAG, error, e);
} finally {
if (parser != null) parser.close();
}
return keyphraseMetadata;
}
private KeyphraseMetadata getKeyphraseFromTypedArray(TypedArray array, String packageName,
List parseErrors) {
// Get the keyphrase ID.
int searchKeyphraseId = array.getInt(
com.android.internal.R.styleable.VoiceEnrollmentApplication_searchKeyphraseId, -1);
if (searchKeyphraseId <= 0) {
String error = "No valid searchKeyphraseId specified in meta-data for " + packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
// Get the keyphrase text.
String searchKeyphrase = array.getString(
com.android.internal.R.styleable.VoiceEnrollmentApplication_searchKeyphrase);
if (searchKeyphrase == null) {
String error = "No valid searchKeyphrase specified in meta-data for " + packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
// Get the supported locales.
String searchKeyphraseSupportedLocales = array.getString(
com.android.internal.R.styleable
.VoiceEnrollmentApplication_searchKeyphraseSupportedLocales);
if (searchKeyphraseSupportedLocales == null) {
String error = "No valid searchKeyphraseSupportedLocales specified in meta-data for "
+ packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
ArraySet locales = new ArraySet<>();
// Try adding locales if the locale string is non-empty.
if (!TextUtils.isEmpty(searchKeyphraseSupportedLocales)) {
try {
String[] supportedLocalesDelimited = searchKeyphraseSupportedLocales.split(",");
for (int i = 0; i < supportedLocalesDelimited.length; i++) {
locales.add(Locale.forLanguageTag(supportedLocalesDelimited[i]));
}
} catch (Exception ex) {
// We catch a generic exception here because we don't want the system service
// to be affected by a malformed metadata because invalid locales were specified
// by the system application.
String error = "Error reading searchKeyphraseSupportedLocales from meta-data for "
+ packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
}
// Get the supported recognition modes.
int recognitionModes = array.getInt(com.android.internal.R.styleable
.VoiceEnrollmentApplication_searchKeyphraseRecognitionFlags, -1);
if (recognitionModes < 0) {
String error = "No valid searchKeyphraseRecognitionFlags specified in meta-data for "
+ packageName;
parseErrors.add(error);
Slog.w(TAG, error);
return null;
}
return new KeyphraseMetadata(searchKeyphraseId, searchKeyphrase, locales, recognitionModes);
}
public String getParseError() {
return mParseError;
}
/**
* @return An array of available keyphrases that can be enrolled on the system.
* It may be null if no keyphrases can be enrolled.
*/
public KeyphraseMetadata[] listKeyphraseMetadata() {
return mKeyphrases;
}
/**
* Returns an intent to launch an activity that manages the given keyphrase
* for the locale.
*
* @param action The enrollment related action that this intent is supposed to perform.
* This can be one of {@link AlwaysOnHotwordDetector#MANAGE_ACTION_ENROLL},
* {@link AlwaysOnHotwordDetector#MANAGE_ACTION_RE_ENROLL}
* or {@link AlwaysOnHotwordDetector#MANAGE_ACTION_UN_ENROLL}
* @param keyphrase The keyphrase that the user needs to be enrolled to.
* @param locale The locale for which the enrollment needs to be performed.
* @return An {@link Intent} to manage the keyphrase. This can be null if managing the
* given keyphrase/locale combination isn't possible.
*/
public Intent getManageKeyphraseIntent(int action, String keyphrase, Locale locale) {
if (mKeyphrasePackageMap == null || mKeyphrasePackageMap.isEmpty()) {
Slog.w(TAG, "No enrollment application exists");
return null;
}
KeyphraseMetadata keyphraseMetadata = getKeyphraseMetadata(keyphrase, locale);
if (keyphraseMetadata != null) {
Intent intent = new Intent(ACTION_MANAGE_VOICE_KEYPHRASES)
.setPackage(mKeyphrasePackageMap.get(keyphraseMetadata))
.putExtra(EXTRA_VOICE_KEYPHRASE_HINT_TEXT, keyphrase)
.putExtra(EXTRA_VOICE_KEYPHRASE_LOCALE, locale.toLanguageTag())
.putExtra(EXTRA_VOICE_KEYPHRASE_ACTION, action);
return intent;
}
return null;
}
/**
* Gets the {@link KeyphraseMetadata} for the given keyphrase and locale, null if any metadata
* isn't available for the given combination.
*
* @param keyphrase The keyphrase that the user needs to be enrolled to.
* @param locale The locale for which the enrollment needs to be performed.
* This is a Java locale, for example "en_US".
* @return The metadata, if the enrollment client supports the given keyphrase
* and locale, null otherwise.
*/
public KeyphraseMetadata getKeyphraseMetadata(String keyphrase, Locale locale) {
if (mKeyphrases != null && mKeyphrases.length > 0) {
for (KeyphraseMetadata keyphraseMetadata : mKeyphrases) {
// Check if the given keyphrase is supported in the locale provided by
// the enrollment application.
if (keyphraseMetadata.supportsPhrase(keyphrase)
&& keyphraseMetadata.supportsLocale(locale)) {
return keyphraseMetadata;
}
}
}
Slog.w(TAG, "No enrollment application supports the given keyphrase/locale: '"
+ keyphrase + "'/" + locale);
return null;
}
@Override
public String toString() {
return "KeyphraseEnrollmentInfo [Keyphrases=" + mKeyphrasePackageMap.toString()
+ ", ParseError=" + mParseError + "]";
}
}