/*
* Copyright (C) 2013 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 com.android.utils.labeling;
import android.annotation.TargetApi;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import android.os.Build;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import android.view.accessibility.AccessibilityNodeInfo;
import com.android.talkback.BuildConfig;
import com.android.talkback.labeling.HasImportedLabelsRequest;
import com.android.talkback.labeling.LabelsFetchRequest;
import com.android.talkback.labeling.CustomLabelMigrationManager;
import com.android.talkback.labeling.DataConsistencyCheckRequest;
import com.android.talkback.labeling.DirectLabelFetchRequest;
import com.android.talkback.labeling.ImportLabelRequest;
import com.android.talkback.labeling.LabelAddRequest;
import com.android.talkback.labeling.LabelClientRequest;
import com.android.talkback.labeling.LabelRemoveRequest;
import com.android.talkback.labeling.LabelTask;
import com.android.talkback.labeling.LabelUpdateRequest;
import com.android.talkback.labeling.PackageLabelsFetchRequest;
import com.android.talkback.labeling.RevertImportedLabelsRequest;
import com.android.utils.AccessibilityEventListener;
import com.android.utils.LogUtils;
import com.android.utils.StringBuilderUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NavigableSet;
import java.util.TreeSet;
import java.util.regex.Pattern;
/**
* Manages logic for prefetching, retrieval, addition, updating, and removal of
* custom view labels and their associated resources.
* <p>
* This class ties together an underlying label database with a LRU label cache.
* It provides convenience methods for accessing and changing the state of
* labels, both persisted and in memory. Methods in this class will often return
* nothing, and may expose asynchronous callbacks wrapped by request classes to
* return results from processing activities on different threads.
* <p>
* This class also serves as an {@link AccessibilityEventListener} for purposes
* of automatically prefetching labels into the managed cache.
*/
// TODO Most public methods in this class should support optional callbacks.
@TargetApi(18)
public class CustomLabelManager {
/** The minimum API level supported by the manager. */
public static final int MIN_API_LEVEL = Build.VERSION_CODES.JELLY_BEAN_MR2;
public static final int SOURCE_TYPE_USER = 0; // labels that were inserted by user
public static final int SOURCE_TYPE_IMPORT = 1; // labels that were imported
public static final int SOURCE_TYPE_BACKUP = 2; // labels that were overridden by import
// Intent values for broadcasts to CustomLabelManager.
public static final String ACTION_REFRESH_LABEL_CACHE =
"com.google.android.marvin.talkback.labeling.REFRESH_LABEL_CACHE";
public static final String EXTRA_STRING_ARRAY_PACKAGES = "EXTRA_STRING_ARRAY_PACKAGES";
private static final String
AUTHORITY = BuildConfig.APPLICATION_ID + ".providers.LabelProvider";
/**
* The substring separating a label's package and view ID name in a
* fully-qualified resource identifier.
*/
private static final Pattern RESOURCE_NAME_SPLIT_PATTERN = Pattern.compile(":id/");
private static final IntentFilter REFRESH_INTENT_FILTER =
new IntentFilter(ACTION_REFRESH_LABEL_CACHE);
public static String getDefaultLocale() {
String locale = Locale.getDefault().toString();
return getLanguageLocale(locale);
}
public static String getLanguageLocale(String locale) {
if (locale != null) {
int localeDivider = locale.indexOf('_');
if (localeDivider > 0) {
return locale.substring(0, localeDivider);
}
}
return locale;
}
private final NavigableSet<Label> mLabelCache = new TreeSet<>(new Comparator<Label>() {
// Note this comparator is not consistent with equals in Label, we should just implement
// compareTo there, but will wait for BrailleBack to be merged with TalkBack
@Override
public int compare(Label first, Label second) {
if (first == null) {
if (second == null) {
return 0;
} else {
return -1;
}
}
if (second == null) return 1;
int ret = first.getPackageName().compareTo(second.getPackageName());
if (ret != 0) return ret;
return first.getViewName().compareTo(second.getViewName());
}
});
private final CacheRefreshReceiver mRefreshReceiver = new CacheRefreshReceiver();
private final LocaleChangedReceiver mLocaleChangedReceiver = new LocaleChangedReceiver();
private final Context mContext;
private final PackageManager mPackageManager;
private final LabelProviderClient mClient;
// Used to manage release of resources based on task completion
private boolean mShouldShutdownClient;
private int mRunningTasks;
private LabelTask.TrackedTaskCallback mTaskCallback =
new LabelTask.TrackedTaskCallback() {
@Override
public void onTaskPreExecute(LabelClientRequest request) {
checkUiThread();
taskStarting(request);
}
@Override
public void onTaskPostExecute(LabelClientRequest request) {
checkUiThread();
taskEnding(request);
}
};
private DataConsistencyCheckRequest.OnDataConsistencyCheckCallback
mDataConsistencyCheckCallback =
new DataConsistencyCheckRequest.OnDataConsistencyCheckCallback() {
@Override
public void onConsistencyCheckCompleted(List<Label> labelsToRemove) {
if (labelsToRemove == null || labelsToRemove.isEmpty()) {
return;
}
LogUtils.log(this, Log.VERBOSE, "Found %d labels to remove during consistency check",
labelsToRemove.size());
for (Label l : labelsToRemove) {
removeLabel(l);
}
}
};
private OnLabelsInPackageChangeListener mLabelsInPackageChangeListener =
new OnLabelsInPackageChangeListener() {
@Override
public void onLabelsInPackageChanged(String packageName) {
sendCacheRefreshIntent(packageName);
}
};
public CustomLabelManager(Context context) {
mContext = context;
mPackageManager = context.getPackageManager();
mClient = new LabelProviderClient(context, AUTHORITY);
mContext.registerReceiver(mRefreshReceiver, REFRESH_INTENT_FILTER);
mContext.registerReceiver(mLocaleChangedReceiver,
new IntentFilter(Intent.ACTION_LOCALE_CHANGED));
refreshCache();
}
private void checkUiThread() {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException("run not on UI thread");
}
}
/**
* Performs various tasks to ensure the underlying Label database is in a
* state consistent with the installed applications on the device. May alter
* the database state by pruning labels in cases where the containing
* applications are no longer present on the system or those applications
* don't match stored signature data.
* <p>
* NOTE: This should be invoked by higher level service entities using this
* class on startup after registering a {@link PackageRemovalReceiver}. It
* is generally unnecessary to invoke this operation for disposable
* instances of this class.
*/
public void ensureDataConsistency() {
if (!isInitialized()) {
return;
}
DataConsistencyCheckRequest request = new DataConsistencyCheckRequest(mClient, mContext,
mDataConsistencyCheckCallback);
LabelTask<List<Label>> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
/**
* Retrieves a {@link Label} from the label cache given a fully-qualified
* resource identifier name.
*
* @param resourceName The fully-qualified resource identifier, such as
* "com.android.deskclock:id/analog_appwidget", as provided by
* {@link AccessibilityNodeInfo#getViewIdResourceName()}
* @return The {@link Label} matching the provided identifier, or
* {@code null} if no such label exists or has not yet been fetched
* from storage
*/
public Label getLabelForViewIdFromCache(String resourceName) {
if (!isInitialized()) {
return null;
}
Pair<String, String> parsedId = splitResourceName(resourceName);
if (parsedId == null) {
return null;
}
Label search = new Label(parsedId.first, null, parsedId.second, null, null, 0, null, 0);
Label result = mLabelCache.ceiling(search);
// TODO: This can be done much simplier with modifying equals in Label but want to wait
// until BrailleBack is integrate to ensure compatibility
if (result != null &&
search.getViewName() != null &&
search.getViewName().equals(result.getViewName()) &&
search.getPackageName() != null &&
search.getPackageName().equals(result.getPackageName())) {
return result;
}
return null;
}
/**
* Retrieves a {@link Label} directly through the database and returns it
* through a callback interface.
*
* @param labelId The id of the label to retrieve from the database as
* provided by {@link Label#getId()}
* @param callback The {@link DirectLabelFetchRequest.OnLabelFetchedListener} to return the
* label though
*/
public void getLabelForLabelIdFromDatabase(long labelId,
DirectLabelFetchRequest.OnLabelFetchedListener callback) {
if (!isInitialized()) {
return;
}
DirectLabelFetchRequest request = new DirectLabelFetchRequest(mClient, labelId, callback);
LabelTask<Label> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
/**
* Retrieves a {@link Map} of view ID resource names to {@link Label}s for
* labels in the given package name and returns it through a callback
* interface.
*
* @param packageName The package name of the labels to retrieve
* @param callback The {@link PackageLabelsFetchRequest.OnLabelsFetchedListener} to return the
* labels through
*/
public void getLabelsForPackageFromDatabase(
String packageName, PackageLabelsFetchRequest.OnLabelsFetchedListener callback) {
if (!isInitialized()) {
return;
}
final PackageLabelsFetchRequest request = new PackageLabelsFetchRequest(mClient, mContext,
packageName, callback);
LabelTask<Map<String, Label>> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
public void getLabelsFromDatabase(
LabelsFetchRequest.OnLabelsFetchedListener callback) {
if (!isInitialized()) {
return;
}
final LabelsFetchRequest request = new LabelsFetchRequest(mClient, callback);
LabelTask<List<Label>> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
/**
* Creates a {@link Label} and persists it to the label database, and
* refreshes the label cache.
*
* @param resourceName The fully-qualified resource identifier, such as
* "com.android.deskclock:id/analog_appwidget", as provided by
* {@link AccessibilityNodeInfo#getViewIdResourceName()}
* @param userLabel The label provided for the node by the user
*/
public void addLabel(String resourceName, String userLabel) {
if (!isInitialized()) {
return;
}
final String finalLabel;
if (userLabel == null) {
throw new IllegalArgumentException(
"Attempted to add a label with a null userLabel value");
} else {
finalLabel = userLabel.trim();
if (TextUtils.isEmpty(finalLabel)) {
throw new IllegalArgumentException(
"Attempted to add a label with an empty userLabel value");
}
}
Pair<String, String> parsedId = splitResourceName(resourceName);
if (parsedId == null) {
LogUtils.log(this, Log.WARN,
"Attempted to add a label with an invalid or poorly formed view ID.");
return;
}
final PackageInfo packageInfo;
try {
packageInfo = mPackageManager.getPackageInfo(
parsedId.first, PackageManager.GET_SIGNATURES);
} catch (NameNotFoundException e) {
LogUtils.log(this, Log.WARN, "Attempted to add a label for an unknown package.");
return;
}
String locale = getDefaultLocale();
final int version = packageInfo.versionCode;
final long timestamp = System.currentTimeMillis();
String signatureHash = "";
final Signature[] sigs = packageInfo.signatures;
try {
final MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
for (Signature s : sigs) {
messageDigest.update(s.toByteArray());
}
signatureHash = StringBuilderUtils.bytesToHexString(messageDigest.digest());
} catch (NoSuchAlgorithmException e) {
LogUtils.log(this, Log.WARN, "Unable to create SHA-1 MessageDigest");
}
// For the current implementation, screenshots are disabled
Label label = new Label(parsedId.first, signatureHash, parsedId.second, finalLabel,
locale, version, "", timestamp);
LabelAddRequest request = new LabelAddRequest(mClient, label, SOURCE_TYPE_USER,
mLabelsInPackageChangeListener);
LabelTask<Label> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
/**
* Updates {@link Label}s in the label database and refreshes the label
* cache.
* <p>
* NOTE: This method relies on the id field of the {@link Label}s being
* populated, so callers must obtain fully populated objects from
* {@link #getLabelForViewIdFromCache(String)} or
* in order to update them.
*
* @param labels The {@link Label}s to remove
*/
public void updateLabel(Label... labels) {
if (!isInitialized()) {
return;
}
if (labels == null || labels.length == 0) {
LogUtils.log(this, Log.WARN, "Attempted to update a null or empty array of labels.");
return;
}
for (Label l : labels) {
if (l == null) {
throw new IllegalArgumentException("Attempted to update a null label.");
}
if (TextUtils.isEmpty(l.getText())) {
throw new IllegalArgumentException(
"Attempted to update a label with an empty text value");
}
LabelUpdateRequest request = new LabelUpdateRequest(mClient, l,
mLabelsInPackageChangeListener);
LabelTask<Boolean> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
}
/**
* Removes {@link Label}s from the label database and refreshes the label
* cache.
* <p>
* NOTE: This method relies on the id field of the {@link Label}s being
* populated, so callers must obtain fully populated objects from
* {@link #getLabelForViewIdFromCache(String)} or
* in order to remove them.
*
* @param labels The {@link Label}s to remove
*/
public void removeLabel(Label... labels) {
if (!isInitialized()) {
return;
}
if (labels == null || labels.length == 0) {
LogUtils.log(this, Log.WARN, "Attempted to delete a null or empty array of labels.");
return;
}
for (Label l : labels) {
LabelRemoveRequest request = new LabelRemoveRequest(mClient, l,
mLabelsInPackageChangeListener);
LabelTask<Boolean> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
}
public void importLabels(List<Label> labels, boolean overrideExistentLabels,
final CustomLabelMigrationManager.OnLabelMigrationCallback callback) {
ImportLabelRequest request = new ImportLabelRequest(mClient, labels, overrideExistentLabels,
new ImportLabelRequest.OnImportLabelCallback() {
@Override
public void onLabelImported(int changedLabelsCount) {
sendCacheRefreshIntent();
if (callback != null) {
callback.onLabelImported(changedLabelsCount);
}
}
});
LabelTask<Integer> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
public void hasImportedLabels(HasImportedLabelsRequest.OnHasImportedLabelsCompleteListener
listener) {
HasImportedLabelsRequest request = new HasImportedLabelsRequest(mClient, listener);
LabelTask<Boolean> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
public void revertImportedLabels(
final RevertImportedLabelsRequest.OnImportLabelsRevertedListener listener) {
RevertImportedLabelsRequest request = new RevertImportedLabelsRequest(mClient,
new RevertImportedLabelsRequest.OnImportLabelsRevertedListener() {
@Override
public void onImportLabelsReverted() {
sendCacheRefreshIntent();
if (listener != null) {
listener.onImportLabelsReverted();
}
}
});
LabelTask<Boolean> task = new LabelTask<>(request, mTaskCallback);
task.execute();
}
/**
* Invalidates and rebuilds the cache of labels managed by this class.
*/
private void refreshCache() {
getLabelsFromDatabase(new LabelsFetchRequest.OnLabelsFetchedListener() {
@Override
public void onLabelsFetched(List<Label> results) {
mLabelCache.clear();
String currentLocale = getDefaultLocale();
for (Label newLabel : results) {
String locale = newLabel.getLocale();
if (locale != null && locale.startsWith(currentLocale)) {
mLabelCache.add(newLabel);
}
}
}
});
}
/**
* If there are no cached labels (possibly because CE storage was not yet available when the
* CustomLabelManager instance was constructed), refreshes the labels from the label provider.
*/
public void ensureLabelsLoaded() {
if (mLabelCache.isEmpty()) {
refreshCache();
}
}
/**
* Splits a fully-qualified resource identifier name into its package and ID
* name.
*
* @param resourceName The fully-qualified resource identifier, such as
* "com.android.deskclock:id/analog_appwidget", as provided by
* {@link AccessibilityNodeInfo#getViewIdResourceName()}
* @return A {@link Pair} where the first value is the package name and
* second is the id name
*/
public static Pair<String, String> splitResourceName(String resourceName) {
if (TextUtils.isEmpty(resourceName)) {
return null;
}
final String[] splitId = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2);
if (splitId.length != 2 || TextUtils.isEmpty(splitId[0]) || TextUtils.isEmpty(splitId[1])) {
// Invalid input
LogUtils.log(CustomLabelManager.class, Log.WARN, "Failed to parse resource: %s",
resourceName);
return null;
}
return new Pair<>(splitId[0], splitId[1]);
}
/**
* Shuts down the manager and releases resources.
*/
public void shutdown() {
LogUtils.log(this, Log.VERBOSE, "Shutdown requested.");
// We must immediately destroy registered receivers to prevent a leak,
// as the context backing this registration is to be invalidated.
mContext.unregisterReceiver(mRefreshReceiver);
mContext.unregisterReceiver(mLocaleChangedReceiver);
// We cannot shutdown resources related to the database until all tasks
// have completed. Flip the flag to indicate a client of this manager
// requested a shutdown and attempt the operation.
mShouldShutdownClient = true;
maybeShutdownClient();
}
/**
* Returns whether the labeling client is properly initialized.
* @return {@code true} if client is ready, or {@code false} otherwise.
*/
public boolean isInitialized() {
checkUiThread();
return mClient.isInitialized();
}
/**
* Shuts down the database resources held by an instance of this manager if
* certain conditions are met. The database resource is released if and only
* if a client has requested a shutdown operation and there are no
* asynchronous operations running. To ensure completeness, this method is
* invoked when a client of this manager requests a shutdown and when any
* asynchronous operation completes.
*/
private void maybeShutdownClient() {
checkUiThread();
if ((mRunningTasks == 0) && mShouldShutdownClient) {
LogUtils.log(this, Log.VERBOSE,
"All tasks completed and shutdown requested. Releasing database.");
mClient.shutdown();
}
}
/**
* Updates the internals of the manager to track this task, keeping database
* resources from being shutdown until all tasks complete.
*
* @param request The task that's starting
*/
private void taskStarting(LabelClientRequest request) {
LogUtils.log(this, Log.VERBOSE, "Task %s starting.", request);
mRunningTasks++;
}
/**
* Updates the internals of the manager to stop tracking this task. May
* dispose of database resources if a shutdown requested by this classes's
* client was requested prior to {@code task}'s completion.
*
* @param request The request that is ending
*/
private void taskEnding(LabelClientRequest request) {
LogUtils.log(this, Log.VERBOSE, "Task %s ending.", request);
mRunningTasks--;
maybeShutdownClient();
}
private void sendCacheRefreshIntent(String... packageNames) {
final Intent refreshIntent = new Intent(ACTION_REFRESH_LABEL_CACHE);
refreshIntent.putExtra(EXTRA_STRING_ARRAY_PACKAGES, packageNames);
mContext.sendBroadcast(refreshIntent);
}
private class LocaleChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
refreshCache();
}
}
private class CacheRefreshReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
refreshCache();
}
}
public interface OnLabelsInPackageChangeListener {
public void onLabelsInPackageChanged(String packageName);
}
}