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><{@link 65 * android.R.styleable#VoiceEnrollmentApplication 66 * voice-enrollment-application}></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