1/*
2 * Copyright (C) 2013 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 com.android.settings.location;
18
19import android.content.Context;
20import android.content.Intent;
21import android.content.pm.ApplicationInfo;
22import android.content.pm.PackageManager;
23import android.content.pm.ResolveInfo;
24import android.content.pm.ServiceInfo;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.content.res.XmlResourceParser;
28import android.graphics.drawable.Drawable;
29import android.location.SettingInjectorService;
30import android.os.Bundle;
31import android.os.Handler;
32import android.os.Message;
33import android.os.Messenger;
34import android.os.SystemClock;
35import android.preference.Preference;
36import android.util.AttributeSet;
37import android.util.Log;
38import android.util.Xml;
39import com.android.settings.R;
40import org.xmlpull.v1.XmlPullParser;
41import org.xmlpull.v1.XmlPullParserException;
42
43import java.io.IOException;
44import java.util.ArrayList;
45import java.util.HashSet;
46import java.util.Iterator;
47import java.util.List;
48import java.util.Set;
49
50/**
51 * Adds the preferences specified by the {@link InjectedSetting} objects to a preference group.
52 *
53 * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}. We do not use that
54 * class directly because it is not a good match for our use case: we do not need the caching, and
55 * so do not want the additional resource hit at app install/upgrade time; and we would have to
56 * suppress the tie-breaking between multiple services reporting settings with the same name.
57 * Code-sharing would require extracting {@link
58 * android.content.pm.RegisteredServicesCache#parseServiceAttributes(android.content.res.Resources,
59 * String, android.util.AttributeSet)} into an interface, which didn't seem worth it.
60 */
61class SettingsInjector {
62    static final String TAG = "SettingsInjector";
63
64    /**
65     * If reading the status of a setting takes longer than this, we go ahead and start reading
66     * the next setting.
67     */
68    private static final long INJECTED_STATUS_UPDATE_TIMEOUT_MILLIS = 1000;
69
70    /**
71     * {@link Message#what} value for starting to load status values
72     * in case we aren't already in the process of loading them.
73     */
74    private static final int WHAT_RELOAD = 1;
75
76    /**
77     * {@link Message#what} value sent after receiving a status message.
78     */
79    private static final int WHAT_RECEIVED_STATUS = 2;
80
81    /**
82     * {@link Message#what} value sent after the timeout waiting for a status message.
83     */
84    private static final int WHAT_TIMEOUT = 3;
85
86    private final Context mContext;
87
88    /**
89     * The settings that were injected
90     */
91    private final Set<Setting> mSettings;
92
93    private final Handler mHandler;
94
95    public SettingsInjector(Context context) {
96        mContext = context;
97        mSettings = new HashSet<Setting>();
98        mHandler = new StatusLoadingHandler();
99    }
100
101    /**
102     * Returns a list with one {@link InjectedSetting} object for each {@link android.app.Service}
103     * that responds to {@link SettingInjectorService#ACTION_SERVICE_INTENT} and provides the
104     * expected setting metadata.
105     *
106     * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}.
107     *
108     * TODO: unit test
109     */
110    private List<InjectedSetting> getSettings() {
111        PackageManager pm = mContext.getPackageManager();
112        Intent intent = new Intent(SettingInjectorService.ACTION_SERVICE_INTENT);
113
114        List<ResolveInfo> resolveInfos =
115                pm.queryIntentServices(intent, PackageManager.GET_META_DATA);
116        if (Log.isLoggable(TAG, Log.DEBUG)) {
117            Log.d(TAG, "Found services: " + resolveInfos);
118        }
119        List<InjectedSetting> settings = new ArrayList<InjectedSetting>(resolveInfos.size());
120        for (ResolveInfo resolveInfo : resolveInfos) {
121            try {
122                InjectedSetting setting = parseServiceInfo(resolveInfo, pm);
123                if (setting == null) {
124                    Log.w(TAG, "Unable to load service info " + resolveInfo);
125                } else {
126                    settings.add(setting);
127                }
128            } catch (XmlPullParserException e) {
129                Log.w(TAG, "Unable to load service info " + resolveInfo, e);
130            } catch (IOException e) {
131                Log.w(TAG, "Unable to load service info " + resolveInfo, e);
132            }
133        }
134        if (Log.isLoggable(TAG, Log.DEBUG)) {
135            Log.d(TAG, "Loaded settings: " + settings);
136        }
137
138        return settings;
139    }
140
141    /**
142     * Returns the settings parsed from the attributes of the
143     * {@link SettingInjectorService#META_DATA_NAME} tag, or null.
144     *
145     * Duplicates some code from {@link android.content.pm.RegisteredServicesCache}.
146     */
147    private static InjectedSetting parseServiceInfo(ResolveInfo service, PackageManager pm)
148            throws XmlPullParserException, IOException {
149
150        ServiceInfo si = service.serviceInfo;
151        ApplicationInfo ai = si.applicationInfo;
152
153        if ((ai.flags & ApplicationInfo.FLAG_SYSTEM) == 0) {
154            if (Log.isLoggable(TAG, Log.WARN)) {
155                Log.w(TAG, "Ignoring attempt to inject setting from app not in system image: "
156                        + service);
157                return null;
158            }
159        }
160
161        XmlResourceParser parser = null;
162        try {
163            parser = si.loadXmlMetaData(pm, SettingInjectorService.META_DATA_NAME);
164            if (parser == null) {
165                throw new XmlPullParserException("No " + SettingInjectorService.META_DATA_NAME
166                        + " meta-data for " + service + ": " + si);
167            }
168
169            AttributeSet attrs = Xml.asAttributeSet(parser);
170
171            int type;
172            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
173                    && type != XmlPullParser.START_TAG) {
174            }
175
176            String nodeName = parser.getName();
177            if (!SettingInjectorService.ATTRIBUTES_NAME.equals(nodeName)) {
178                throw new XmlPullParserException("Meta-data does not start with "
179                        + SettingInjectorService.ATTRIBUTES_NAME + " tag");
180            }
181
182            Resources res = pm.getResourcesForApplication(ai);
183            return parseAttributes(si.packageName, si.name, res, attrs);
184        } catch (PackageManager.NameNotFoundException e) {
185            throw new XmlPullParserException(
186                    "Unable to load resources for package " + si.packageName);
187        } finally {
188            if (parser != null) {
189                parser.close();
190            }
191        }
192    }
193
194    /**
195     * Returns an immutable representation of the static attributes for the setting, or null.
196     */
197    private static InjectedSetting parseAttributes(
198            String packageName, String className, Resources res, AttributeSet attrs) {
199
200        TypedArray sa = res.obtainAttributes(attrs, android.R.styleable.SettingInjectorService);
201        try {
202            // Note that to help guard against malicious string injection, we do not allow dynamic
203            // specification of the label (setting title)
204            final String title = sa.getString(android.R.styleable.SettingInjectorService_title);
205            final int iconId =
206                    sa.getResourceId(android.R.styleable.SettingInjectorService_icon, 0);
207            final String settingsActivity =
208                    sa.getString(android.R.styleable.SettingInjectorService_settingsActivity);
209            if (Log.isLoggable(TAG, Log.DEBUG)) {
210                Log.d(TAG, "parsed title: " + title + ", iconId: " + iconId
211                        + ", settingsActivity: " + settingsActivity);
212            }
213            return InjectedSetting.newInstance(packageName, className,
214                    title, iconId, settingsActivity);
215        } finally {
216            sa.recycle();
217        }
218    }
219
220    /**
221     * Gets a list of preferences that other apps have injected.
222     */
223    public List<Preference> getInjectedSettings() {
224        Iterable<InjectedSetting> settings = getSettings();
225        ArrayList<Preference> prefs = new ArrayList<Preference>();
226        for (InjectedSetting setting : settings) {
227            Preference pref = addServiceSetting(prefs, setting);
228            mSettings.add(new Setting(setting, pref));
229        }
230
231        reloadStatusMessages();
232
233        return prefs;
234    }
235
236    /**
237     * Reloads the status messages for all the preference items.
238     */
239    public void reloadStatusMessages() {
240        if (Log.isLoggable(TAG, Log.DEBUG)) {
241            Log.d(TAG, "reloadingStatusMessages: " + mSettings);
242        }
243        mHandler.sendMessage(mHandler.obtainMessage(WHAT_RELOAD));
244    }
245
246    /**
247     * Adds an injected setting to the root with status "Loading...".
248     */
249    private Preference addServiceSetting(List<Preference> prefs, InjectedSetting info) {
250        Preference pref = new Preference(mContext);
251        pref.setTitle(info.title);
252        pref.setSummary(R.string.location_loading_injected_setting);
253        PackageManager pm = mContext.getPackageManager();
254        Drawable icon = pm.getDrawable(info.packageName, info.iconId, null);
255        pref.setIcon(icon);
256
257        Intent settingIntent = new Intent();
258        settingIntent.setClassName(info.packageName, info.settingsActivity);
259        pref.setIntent(settingIntent);
260
261        prefs.add(pref);
262        return pref;
263    }
264
265    /**
266     * Loads the setting status values one at a time. Each load starts a subclass of {@link
267     * SettingInjectorService}, so to reduce memory pressure we don't want to load too many at
268     * once.
269     */
270    private final class StatusLoadingHandler extends Handler {
271
272        /**
273         * Settings whose status values need to be loaded. A set is used to prevent redundant loads.
274         */
275        private Set<Setting> mSettingsToLoad = new HashSet<Setting>();
276
277        /**
278         * Settings that are being loaded now and haven't timed out. In practice this should have
279         * zero or one elements.
280         */
281        private Set<Setting> mSettingsBeingLoaded = new HashSet<Setting>();
282
283        /**
284         * Settings that are being loaded but have timed out. If only one setting has timed out, we
285         * will go ahead and start loading the next setting so that one slow load won't delay the
286         * load of the other settings.
287         */
288        private Set<Setting> mTimedOutSettings = new HashSet<Setting>();
289
290        private boolean mReloadRequested;
291
292        @Override
293        public void handleMessage(Message msg) {
294            if (Log.isLoggable(TAG, Log.DEBUG)) {
295                Log.d(TAG, "handleMessage start: " + msg + ", " + this);
296            }
297
298            // Update state in response to message
299            switch (msg.what) {
300                case WHAT_RELOAD:
301                    mReloadRequested = true;
302                    break;
303                case WHAT_RECEIVED_STATUS:
304                    final Setting receivedSetting = (Setting) msg.obj;
305                    receivedSetting.maybeLogElapsedTime();
306                    mSettingsBeingLoaded.remove(receivedSetting);
307                    mTimedOutSettings.remove(receivedSetting);
308                    removeMessages(WHAT_TIMEOUT, receivedSetting);
309                    break;
310                case WHAT_TIMEOUT:
311                    final Setting timedOutSetting = (Setting) msg.obj;
312                    mSettingsBeingLoaded.remove(timedOutSetting);
313                    mTimedOutSettings.add(timedOutSetting);
314                    if (Log.isLoggable(TAG, Log.WARN)) {
315                        Log.w(TAG, "Timed out after " + timedOutSetting.getElapsedTime()
316                                + " millis trying to get status for: " + timedOutSetting);
317                    }
318                    break;
319                default:
320                    Log.wtf(TAG, "Unexpected what: " + msg);
321            }
322
323            // Decide whether to load additional settings based on the new state. Start by seeing
324            // if we have headroom to load another setting.
325            if (mSettingsBeingLoaded.size() > 0 || mTimedOutSettings.size() > 1) {
326                // Don't load any more settings until one of the pending settings has completed.
327                // To reduce memory pressure, we want to be loading at most one setting (plus at
328                // most one timed-out setting) at a time. This means we'll be responsible for
329                // bringing in at most two services.
330                if (Log.isLoggable(TAG, Log.VERBOSE)) {
331                    Log.v(TAG, "too many services already live for " + msg + ", " + this);
332                }
333                return;
334            }
335
336            if (mReloadRequested && mSettingsToLoad.isEmpty() && mSettingsBeingLoaded.isEmpty()
337                    && mTimedOutSettings.isEmpty()) {
338                if (Log.isLoggable(TAG, Log.VERBOSE)) {
339                    Log.v(TAG, "reloading because idle and reload requesteed " + msg + ", " + this);
340                }
341                // Reload requested, so must reload all settings
342                mSettingsToLoad.addAll(mSettings);
343                mReloadRequested = false;
344            }
345
346            // Remove the next setting to load from the queue, if any
347            Iterator<Setting> iter = mSettingsToLoad.iterator();
348            if (!iter.hasNext()) {
349                if (Log.isLoggable(TAG, Log.VERBOSE)) {
350                    Log.v(TAG, "nothing left to do for " + msg + ", " + this);
351                }
352                return;
353            }
354            Setting setting = iter.next();
355            iter.remove();
356
357            // Request the status value
358            setting.startService();
359            mSettingsBeingLoaded.add(setting);
360
361            // Ensure that if receiving the status value takes too long, we start loading the
362            // next value anyway
363            Message timeoutMsg = obtainMessage(WHAT_TIMEOUT, setting);
364            sendMessageDelayed(timeoutMsg, INJECTED_STATUS_UPDATE_TIMEOUT_MILLIS);
365
366            if (Log.isLoggable(TAG, Log.DEBUG)) {
367                Log.d(TAG, "handleMessage end " + msg + ", " + this
368                        + ", started loading " + setting);
369            }
370        }
371
372        @Override
373        public String toString() {
374            return "StatusLoadingHandler{" +
375                    "mSettingsToLoad=" + mSettingsToLoad +
376                    ", mSettingsBeingLoaded=" + mSettingsBeingLoaded +
377                    ", mTimedOutSettings=" + mTimedOutSettings +
378                    ", mReloadRequested=" + mReloadRequested +
379                    '}';
380        }
381    }
382
383    /**
384     * Represents an injected setting and the corresponding preference.
385     */
386    private final class Setting {
387
388        public final InjectedSetting setting;
389        public final Preference preference;
390        public long startMillis;
391
392        private Setting(InjectedSetting setting, Preference preference) {
393            this.setting = setting;
394            this.preference = preference;
395        }
396
397        @Override
398        public String toString() {
399            return "Setting{" +
400                    "setting=" + setting +
401                    ", preference=" + preference +
402                    '}';
403        }
404
405        /**
406         * Returns true if they both have the same {@link #setting} value. Ignores mutable
407         * {@link #preference} and {@link #startMillis} so that it's safe to use in sets.
408         */
409        @Override
410        public boolean equals(Object o) {
411            return this == o || o instanceof Setting && setting.equals(((Setting) o).setting);
412        }
413
414        @Override
415        public int hashCode() {
416            return setting.hashCode();
417        }
418
419        /**
420         * Starts the service to fetch for the current status for the setting, and updates the
421         * preference when the service replies.
422         */
423        public void startService() {
424            Handler handler = new Handler() {
425                @Override
426                public void handleMessage(Message msg) {
427                    Bundle bundle = msg.getData();
428                    String summary = bundle.getString(SettingInjectorService.SUMMARY_KEY);
429                    boolean enabled = bundle.getBoolean(SettingInjectorService.ENABLED_KEY, true);
430                    if (Log.isLoggable(TAG, Log.DEBUG)) {
431                        Log.d(TAG, setting + ": received " + msg + ", bundle: " + bundle);
432                    }
433                    preference.setSummary(summary);
434                    preference.setEnabled(enabled);
435                    mHandler.sendMessage(
436                            mHandler.obtainMessage(WHAT_RECEIVED_STATUS, Setting.this));
437                }
438            };
439            Messenger messenger = new Messenger(handler);
440
441            Intent intent = setting.getServiceIntent();
442            intent.putExtra(SettingInjectorService.MESSENGER_KEY, messenger);
443
444            if (Log.isLoggable(TAG, Log.DEBUG)) {
445                Log.d(TAG, setting + ": sending update intent: " + intent
446                        + ", handler: " + handler);
447                startMillis = SystemClock.elapsedRealtime();
448            } else {
449                startMillis = 0;
450            }
451
452            // Start the service, making sure that this is attributed to the current user rather
453            // than the system user.
454            mContext.startServiceAsUser(intent, android.os.Process.myUserHandle());
455        }
456
457        public long getElapsedTime() {
458            long end = SystemClock.elapsedRealtime();
459            return end - startMillis;
460        }
461
462        public void maybeLogElapsedTime() {
463            if (Log.isLoggable(TAG, Log.DEBUG) && startMillis != 0) {
464                long elapsed = getElapsedTime();
465                Log.d(TAG, this + " update took " + elapsed + " millis");
466            }
467        }
468    }
469}
470