/* * 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.settings.location; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ServiceInfo; import android.content.res.Resources; import android.content.res.TypedArray; import android.content.res.XmlResourceParser; import android.graphics.drawable.Drawable; import android.location.SettingInjectorService; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.os.Messenger; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; import android.preference.Preference; import android.util.AttributeSet; import android.util.Log; import android.util.Xml; import com.android.settings.R; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; /** * Adds the preferences specified by the {@link InjectedSetting} objects to a preference group. * * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}. We do not use that * class directly because it is not a good match for our use case: we do not need the caching, and * so do not want the additional resource hit at app install/upgrade time; and we would have to * suppress the tie-breaking between multiple services reporting settings with the same name. * Code-sharing would require extracting {@link * android.content.pm.RegisteredServicesCache#parseServiceAttributes(android.content.res.Resources, * String, android.util.AttributeSet)} into an interface, which didn't seem worth it. */ class SettingsInjector { static final String TAG = "SettingsInjector"; /** * If reading the status of a setting takes longer than this, we go ahead and start reading * the next setting. */ private static final long INJECTED_STATUS_UPDATE_TIMEOUT_MILLIS = 1000; /** * {@link Message#what} value for starting to load status values * in case we aren't already in the process of loading them. */ private static final int WHAT_RELOAD = 1; /** * {@link Message#what} value sent after receiving a status message. */ private static final int WHAT_RECEIVED_STATUS = 2; /** * {@link Message#what} value sent after the timeout waiting for a status message. */ private static final int WHAT_TIMEOUT = 3; private final Context mContext; /** * The settings that were injected */ private final Set mSettings; private final Handler mHandler; public SettingsInjector(Context context) { mContext = context; mSettings = new HashSet(); mHandler = new StatusLoadingHandler(); } /** * Returns a list for a profile with one {@link InjectedSetting} object for each * {@link android.app.Service} that responds to * {@link SettingInjectorService#ACTION_SERVICE_INTENT} and provides the expected setting * metadata. * * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}. * * TODO: unit test */ private List getSettings(final UserHandle userHandle) { PackageManager pm = mContext.getPackageManager(); Intent intent = new Intent(SettingInjectorService.ACTION_SERVICE_INTENT); final int profileId = userHandle.getIdentifier(); List resolveInfos = pm.queryIntentServicesAsUser(intent, PackageManager.GET_META_DATA, profileId); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Found services for profile id " + profileId + ": " + resolveInfos); } List settings = new ArrayList(resolveInfos.size()); for (ResolveInfo resolveInfo : resolveInfos) { try { InjectedSetting setting = parseServiceInfo(resolveInfo, userHandle, pm); if (setting == null) { Log.w(TAG, "Unable to load service info " + resolveInfo); } else { settings.add(setting); } } catch (XmlPullParserException e) { Log.w(TAG, "Unable to load service info " + resolveInfo, e); } catch (IOException e) { Log.w(TAG, "Unable to load service info " + resolveInfo, e); } } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Loaded settings for profile id " + profileId + ": " + settings); } return settings; } /** * Returns the settings parsed from the attributes of the * {@link SettingInjectorService#META_DATA_NAME} tag, or null. * * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}. */ private static InjectedSetting parseServiceInfo(ResolveInfo service, UserHandle userHandle, PackageManager pm) throws XmlPullParserException, IOException { ServiceInfo si = service.serviceInfo; ApplicationInfo ai = si.applicationInfo; if ((ai.flags & ApplicationInfo.FLAG_SYSTEM) == 0) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Ignoring attempt to inject setting from app not in system image: " + service); return null; } } XmlResourceParser parser = null; try { parser = si.loadXmlMetaData(pm, SettingInjectorService.META_DATA_NAME); if (parser == null) { throw new XmlPullParserException("No " + SettingInjectorService.META_DATA_NAME + " meta-data for " + service + ": " + si); } AttributeSet attrs = Xml.asAttributeSet(parser); int type; while ((type = parser.next()) != XmlPullParser.END_DOCUMENT && type != XmlPullParser.START_TAG) { } String nodeName = parser.getName(); if (!SettingInjectorService.ATTRIBUTES_NAME.equals(nodeName)) { throw new XmlPullParserException("Meta-data does not start with " + SettingInjectorService.ATTRIBUTES_NAME + " tag"); } Resources res = pm.getResourcesForApplicationAsUser(si.packageName, userHandle.getIdentifier()); return parseAttributes(si.packageName, si.name, userHandle, res, attrs); } catch (PackageManager.NameNotFoundException e) { throw new XmlPullParserException( "Unable to load resources for package " + si.packageName); } finally { if (parser != null) { parser.close(); } } } /** * Returns an immutable representation of the static attributes for the setting, or null. */ private static InjectedSetting parseAttributes(String packageName, String className, UserHandle userHandle, Resources res, AttributeSet attrs) { TypedArray sa = res.obtainAttributes(attrs, android.R.styleable.SettingInjectorService); try { // Note that to help guard against malicious string injection, we do not allow dynamic // specification of the label (setting title) final String title = sa.getString(android.R.styleable.SettingInjectorService_title); final int iconId = sa.getResourceId(android.R.styleable.SettingInjectorService_icon, 0); final String settingsActivity = sa.getString(android.R.styleable.SettingInjectorService_settingsActivity); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "parsed title: " + title + ", iconId: " + iconId + ", settingsActivity: " + settingsActivity); } return InjectedSetting.newInstance(packageName, className, title, iconId, userHandle, settingsActivity); } finally { sa.recycle(); } } /** * Gets a list of preferences that other apps have injected. * * @param profileId Identifier of the user/profile to obtain the injected settings for or * UserHandle.USER_CURRENT for all profiles associated with current user. */ public List getInjectedSettings(final int profileId) { final UserManager um = (UserManager) mContext.getSystemService(Context.USER_SERVICE); final List profiles = um.getUserProfiles(); ArrayList prefs = new ArrayList(); final int profileCount = profiles.size(); for (int i = 0; i < profileCount; ++i) { final UserHandle userHandle = profiles.get(i); if (profileId == UserHandle.USER_CURRENT || profileId == userHandle.getIdentifier()) { Iterable settings = getSettings(userHandle); for (InjectedSetting setting : settings) { Preference pref = addServiceSetting(prefs, setting); mSettings.add(new Setting(setting, pref)); } } } reloadStatusMessages(); return prefs; } /** * Reloads the status messages for all the preference items. */ public void reloadStatusMessages() { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "reloadingStatusMessages: " + mSettings); } mHandler.sendMessage(mHandler.obtainMessage(WHAT_RELOAD)); } /** * Adds an injected setting to the root. */ private Preference addServiceSetting(List prefs, InjectedSetting info) { PackageManager pm = mContext.getPackageManager(); Drawable appIcon = pm.getDrawable(info.packageName, info.iconId, null); Drawable icon = pm.getUserBadgedIcon(appIcon, info.mUserHandle); CharSequence badgedAppLabel = pm.getUserBadgedLabel(info.title, info.mUserHandle); if (info.title.contentEquals(badgedAppLabel)) { // If badged label is not different from original then no need for it as // a separate content description. badgedAppLabel = null; } Preference pref = new DimmableIconPreference(mContext, badgedAppLabel); pref.setTitle(info.title); pref.setSummary(null); pref.setIcon(icon); pref.setOnPreferenceClickListener(new ServiceSettingClickedListener(info)); prefs.add(pref); return pref; } private class ServiceSettingClickedListener implements Preference.OnPreferenceClickListener { private InjectedSetting mInfo; public ServiceSettingClickedListener(InjectedSetting info) { mInfo = info; } @Override public boolean onPreferenceClick(Preference preference) { // Activity to start if they click on the preference. Must start in new task to ensure // that "android.settings.LOCATION_SOURCE_SETTINGS" brings user back to // Settings > Location. Intent settingIntent = new Intent(); settingIntent.setClassName(mInfo.packageName, mInfo.settingsActivity); settingIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); mContext.startActivityAsUser(settingIntent, mInfo.mUserHandle); return true; } } /** * Loads the setting status values one at a time. Each load starts a subclass of {@link * SettingInjectorService}, so to reduce memory pressure we don't want to load too many at * once. */ private final class StatusLoadingHandler extends Handler { /** * Settings whose status values need to be loaded. A set is used to prevent redundant loads. */ private Set mSettingsToLoad = new HashSet(); /** * Settings that are being loaded now and haven't timed out. In practice this should have * zero or one elements. */ private Set mSettingsBeingLoaded = new HashSet(); /** * Settings that are being loaded but have timed out. If only one setting has timed out, we * will go ahead and start loading the next setting so that one slow load won't delay the * load of the other settings. */ private Set mTimedOutSettings = new HashSet(); private boolean mReloadRequested; @Override public void handleMessage(Message msg) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "handleMessage start: " + msg + ", " + this); } // Update state in response to message switch (msg.what) { case WHAT_RELOAD: mReloadRequested = true; break; case WHAT_RECEIVED_STATUS: final Setting receivedSetting = (Setting) msg.obj; receivedSetting.maybeLogElapsedTime(); mSettingsBeingLoaded.remove(receivedSetting); mTimedOutSettings.remove(receivedSetting); removeMessages(WHAT_TIMEOUT, receivedSetting); break; case WHAT_TIMEOUT: final Setting timedOutSetting = (Setting) msg.obj; mSettingsBeingLoaded.remove(timedOutSetting); mTimedOutSettings.add(timedOutSetting); if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Timed out after " + timedOutSetting.getElapsedTime() + " millis trying to get status for: " + timedOutSetting); } break; default: Log.wtf(TAG, "Unexpected what: " + msg); } // Decide whether to load additional settings based on the new state. Start by seeing // if we have headroom to load another setting. if (mSettingsBeingLoaded.size() > 0 || mTimedOutSettings.size() > 1) { // Don't load any more settings until one of the pending settings has completed. // To reduce memory pressure, we want to be loading at most one setting (plus at // most one timed-out setting) at a time. This means we'll be responsible for // bringing in at most two services. if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "too many services already live for " + msg + ", " + this); } return; } if (mReloadRequested && mSettingsToLoad.isEmpty() && mSettingsBeingLoaded.isEmpty() && mTimedOutSettings.isEmpty()) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "reloading because idle and reload requesteed " + msg + ", " + this); } // Reload requested, so must reload all settings mSettingsToLoad.addAll(mSettings); mReloadRequested = false; } // Remove the next setting to load from the queue, if any Iterator iter = mSettingsToLoad.iterator(); if (!iter.hasNext()) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "nothing left to do for " + msg + ", " + this); } return; } Setting setting = iter.next(); iter.remove(); // Request the status value setting.startService(); mSettingsBeingLoaded.add(setting); // Ensure that if receiving the status value takes too long, we start loading the // next value anyway Message timeoutMsg = obtainMessage(WHAT_TIMEOUT, setting); sendMessageDelayed(timeoutMsg, INJECTED_STATUS_UPDATE_TIMEOUT_MILLIS); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "handleMessage end " + msg + ", " + this + ", started loading " + setting); } } @Override public String toString() { return "StatusLoadingHandler{" + "mSettingsToLoad=" + mSettingsToLoad + ", mSettingsBeingLoaded=" + mSettingsBeingLoaded + ", mTimedOutSettings=" + mTimedOutSettings + ", mReloadRequested=" + mReloadRequested + '}'; } } /** * Represents an injected setting and the corresponding preference. */ private final class Setting { public final InjectedSetting setting; public final Preference preference; public long startMillis; private Setting(InjectedSetting setting, Preference preference) { this.setting = setting; this.preference = preference; } @Override public String toString() { return "Setting{" + "setting=" + setting + ", preference=" + preference + '}'; } /** * Returns true if they both have the same {@link #setting} value. Ignores mutable * {@link #preference} and {@link #startMillis} so that it's safe to use in sets. */ @Override public boolean equals(Object o) { return this == o || o instanceof Setting && setting.equals(((Setting) o).setting); } @Override public int hashCode() { return setting.hashCode(); } /** * Starts the service to fetch for the current status for the setting, and updates the * preference when the service replies. */ public void startService() { Handler handler = new Handler() { @Override public void handleMessage(Message msg) { Bundle bundle = msg.getData(); boolean enabled = bundle.getBoolean(SettingInjectorService.ENABLED_KEY, true); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, setting + ": received " + msg + ", bundle: " + bundle); } preference.setSummary(null); preference.setEnabled(enabled); mHandler.sendMessage( mHandler.obtainMessage(WHAT_RECEIVED_STATUS, Setting.this)); } }; Messenger messenger = new Messenger(handler); Intent intent = setting.getServiceIntent(); intent.putExtra(SettingInjectorService.MESSENGER_KEY, messenger); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, setting + ": sending update intent: " + intent + ", handler: " + handler); startMillis = SystemClock.elapsedRealtime(); } else { startMillis = 0; } // Start the service, making sure that this is attributed to the user associated with // the setting rather than the system user. mContext.startServiceAsUser(intent, setting.mUserHandle); } public long getElapsedTime() { long end = SystemClock.elapsedRealtime(); return end - startMillis; } public void maybeLogElapsedTime() { if (Log.isLoggable(TAG, Log.DEBUG) && startMillis != 0) { long elapsed = getElapsedTime(); Log.d(TAG, this + " update took " + elapsed + " millis"); } } } }