cmparts: Add PartsRefresher for updating remote UI components

 * We need to keep the UI up to date with changes to the
   state of parts, for example to refresh the summary of a
   preference when it's changed by the user.
 * This works using broadcasts.

Change-Id: I07bf1358535db6ba40f8ee5a2826537f75e34cba
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 4bd528b..01331f9 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -36,10 +36,11 @@
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
     <uses-permission android:name="android.permission.READ_SEARCH_INDEXABLES" />
 
-    <uses-permission android:name="cyanogenmod.permission.BIND_CORE_SERVICE" />
+    <uses-permission android:name="cyanogenmod.permission.MANAGE_PARTS" />
 
     <protected-broadcast android:name="cyanogenmod.platform.app.profiles.PROFILES_STATE_CHANGED" />
     <protected-broadcast android:name="org.cyanogenmod.cmparts.PART_CHANGED" />
+    <protected-broadcast android:name="org.cyanogenmod.cmparts.REFRESH_PART" />
 
     <application android:label="@string/cmparts_title"
             android:theme="@style/Theme.Settings"
@@ -61,6 +62,12 @@
             </intent-filter>
         </receiver>
 
+        <receiver android:name=".RefreshReceiver" android:enabled="true">
+            <intent-filter>
+                <action android:name="org.cyanogenmod.cmparts.REFRESH_PART" />
+            </intent-filter>
+        </receiver>
+
         <provider android:name=".search.CMPartsSearchIndexablesProvider"
                   android:authorities="org.cyanogenmod.cmparts"
                   android:multiprocess="false"
diff --git a/src/org/cyanogenmod/cmparts/PartsActivity.java b/src/org/cyanogenmod/cmparts/PartsActivity.java
index 34cf2f8..52faa83 100644
--- a/src/org/cyanogenmod/cmparts/PartsActivity.java
+++ b/src/org/cyanogenmod/cmparts/PartsActivity.java
@@ -93,10 +93,10 @@
         if (fragmentClass == null) {
             if (partExtra != null) {
                 // Parts mode
-                info = PartsList.getPartInfo(this, partExtra);
+                info = PartsList.get(this).getPartInfo(partExtra);
             } else {
                 // Alias mode
-                info = PartsList.getPartInfoForClass(this,
+                info = PartsList.get(this).getPartInfoForClass(
                         getIntent().getComponent().getClassName());
                 mHomeAsUp = false;
             }
diff --git a/src/org/cyanogenmod/cmparts/PartsRefresher.java b/src/org/cyanogenmod/cmparts/PartsRefresher.java
new file mode 100644
index 0000000..ca0ae56
--- /dev/null
+++ b/src/org/cyanogenmod/cmparts/PartsRefresher.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod 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 org.cyanogenmod.cmparts;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.UserHandle;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+
+import org.cyanogenmod.internal.cmparts.PartInfo;
+import org.cyanogenmod.internal.cmparts.PartsList;
+import org.cyanogenmod.platform.internal.Manifest;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.cyanogenmod.internal.cmparts.PartsList.ACTION_PART_CHANGED;
+import static org.cyanogenmod.internal.cmparts.PartsList.EXTRA_PART;
+import static org.cyanogenmod.internal.cmparts.PartsList.EXTRA_PART_KEY;
+
+/**
+ * PartsRefresher keeps remote UI clients up to date with any changes in the
+ * state of the Part which should be reflected immediately. For preferences,
+ * the clear use case is refreshing the summary.
+ *
+ * This works in conjunction with CMPartsPreference, which will send an
+ * ordered broadcast requesting updated information. The part will be
+ * looked up, and checked for a static SUMMARY_INFO field. If an
+ * instance of SummaryInfo is found in this field, the result of the
+ * broadcast will be updated with the new information.
+ *
+ * Parts can also call refreshPart to send an asynchronous update to any
+ * active remote components via broadcast.
+ */
+public class PartsRefresher {
+
+    private static final String TAG = PartsRefresher.class.getSimpleName();
+
+    public static final String FIELD_NAME_SUMMARY_PROVIDER = "SUMMARY_PROVIDER";
+
+    private static PartsRefresher sInstance;
+
+    private final Context mContext;
+
+    private final Handler mHandler;
+
+    private final SettingsObserver mObserver;
+
+    private PartsRefresher(Context context) {
+        super();
+        mContext = context;
+        mHandler = new Handler();
+        mObserver = new SettingsObserver();
+    }
+
+    public static synchronized PartsRefresher get(Context context) {
+        if (sInstance == null) {
+            sInstance = new PartsRefresher(context);
+        }
+        return sInstance;
+    }
+
+    private Refreshable.SummaryProvider getPartSummary(PartInfo pi) {
+        final Class<?> clazz;
+        try {
+            clazz = Class.forName(pi.getFragmentClass());
+        } catch (ClassNotFoundException e) {
+            Log.d(TAG, "Cannot find class: " + pi.getFragmentClass());
+            return null;
+        }
+
+        if (clazz == null || !Refreshable.class.isAssignableFrom(clazz)) {
+            return null;
+        }
+
+        try {
+            final Field f = clazz.getField(FIELD_NAME_SUMMARY_PROVIDER);
+            return (Refreshable.SummaryProvider) f.get(null);
+        } catch (Exception e) {
+            // ignore
+        }
+        return null;
+    }
+
+    boolean updateExtras(String key, Bundle bundle) {
+        final PartInfo pi = PartsList.get(mContext).getPartInfo(key);
+        if (pi == null) {
+            return false;
+        }
+
+        final Refreshable.SummaryProvider si = getPartSummary(pi);
+        if (si == null) {
+            return false;
+        }
+
+        String summary = si.getSummary(mContext, key);
+
+        if (Objects.equals(summary, pi.getSummary())) {
+            return false;
+        }
+
+        pi.setSummary(si.getSummary(mContext, key));
+        bundle.putString(EXTRA_PART_KEY, key);
+        bundle.putParcelable(EXTRA_PART, pi);
+        return true;
+    }
+
+    public void refreshPart(String key) {
+        final Intent i = new Intent(ACTION_PART_CHANGED);
+        final Bundle extras = new Bundle();
+        if (updateExtras(key, extras)) {
+            i.putExtras(extras);
+            mContext.sendBroadcastAsUser(i, UserHandle.CURRENT, Manifest.permission.MANAGE_PARTS);
+        }
+    }
+
+    public void addTrigger(Refreshable listener, Uri... contentUris) {
+        mObserver.register(listener, contentUris);
+    }
+
+    public void removeTrigger(Refreshable listener) {
+        mObserver.unregister(listener);
+    }
+
+    public interface Refreshable {
+        public void onRefresh(Context context, Uri what);
+
+        public interface SummaryProvider {
+            public String getSummary(Context context, String key);
+        }
+    }
+
+    private final class SettingsObserver extends ContentObserver {
+
+        private final Map<Refreshable, Set<Uri>> mTriggers = new ArrayMap<>();
+        private final List<Uri> mRefs = new ArrayList<>();
+
+        private final ContentResolver mResolver;
+
+        public SettingsObserver() {
+            super(mHandler);
+
+            mResolver = mContext.getContentResolver();
+        }
+
+        public void register(Refreshable listener, Uri... contentUris) {
+            synchronized (mRefs) {
+                Set<Uri> uris = mTriggers.get(listener);
+                if (uris == null) {
+                    uris = new ArraySet<Uri>();
+                    mTriggers.put(listener, uris);
+                }
+                for (Uri contentUri : contentUris) {
+                    uris.add(contentUri);
+                    if (!mRefs.contains(contentUri)) {
+                        mResolver.registerContentObserver(contentUri, false, this);
+                    }
+                    mRefs.add(contentUri);
+                }
+            }
+        }
+
+        public void unregister(Refreshable listener) {
+            synchronized (mRefs) {
+                Set<Uri> uris = mTriggers.remove(listener);
+                if (uris != null) {
+                    for (Uri uri : uris) {
+                        mRefs.remove(uri);
+                    }
+                }
+                if (mRefs.size() == 0) {
+                    mResolver.unregisterContentObserver(this);
+                }
+            }
+        }
+
+        @Override
+        public void onChange(boolean selfChange, Uri uri) {
+            synchronized (mRefs) {
+                super.onChange(selfChange, uri);
+
+                final Set<Refreshable> notify = new ArraySet<>();
+                for (Map.Entry<Refreshable, Set<Uri>> entry : mTriggers.entrySet()) {
+                    if (entry.getValue().contains(uri)) {
+                        notify.add(entry.getKey());
+                    }
+                }
+
+                for (Refreshable listener : notify) {
+                    listener.onRefresh(mContext, uri);
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/org/cyanogenmod/cmparts/RefreshReceiver.java b/src/org/cyanogenmod/cmparts/RefreshReceiver.java
new file mode 100644
index 0000000..d4ccea9
--- /dev/null
+++ b/src/org/cyanogenmod/cmparts/RefreshReceiver.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod 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 org.cyanogenmod.cmparts;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.cyanogenmod.internal.cmparts.PartsList;
+
+import static org.cyanogenmod.internal.cmparts.PartsList.ACTION_REFRESH_PART;
+
+public class RefreshReceiver extends BroadcastReceiver {
+
+    /**
+     * Receiver which handles clients requesting a summary update. A client may send
+     * the REFERSH_PART action via sendOrderedBroadcast, and we will reply immediately.
+     *
+     * @param context
+     * @param intent
+     */
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (ACTION_REFRESH_PART.equals(intent.getAction()) && isOrderedBroadcast()) {
+            final String key = intent.getStringExtra(PartsList.EXTRA_PART_KEY);
+            if (key != null &&
+                    PartsRefresher.get(context).updateExtras(key, getResultExtras(true))) {
+                setResultCode(Activity.RESULT_OK);
+                return;
+            }
+        }
+        abortBroadcast();
+    }
+}
diff --git a/src/org/cyanogenmod/cmparts/SettingsPreferenceFragment.java b/src/org/cyanogenmod/cmparts/SettingsPreferenceFragment.java
index 03f445a..4d7fcab 100644
--- a/src/org/cyanogenmod/cmparts/SettingsPreferenceFragment.java
+++ b/src/org/cyanogenmod/cmparts/SettingsPreferenceFragment.java
@@ -25,6 +25,7 @@
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.pm.PackageManager;
+import android.net.Uri;
 import android.os.Bundle;
 import android.support.annotation.XmlRes;
 import android.support.v14.preference.PreferenceFragment;
@@ -37,6 +38,7 @@
 import android.support.v7.widget.RecyclerView;
 import android.text.TextUtils;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -46,13 +48,14 @@
 import android.widget.Button;
 import android.view.animation.*;
 
+import java.util.Arrays;
 import java.util.UUID;
 
 /**
  * Base class for Settings fragments, with some helper functions and dialog management.
  */
 public abstract class SettingsPreferenceFragment extends PreferenceFragment
-        implements DialogCreatable {
+        implements DialogCreatable, PartsRefresher.Refreshable {
 
     /**
      * The Help Uri Resource key. This can be passed as an extra argument when creating the
@@ -102,6 +105,8 @@
     private ArrayMap<String, Preference> mPreferenceCache;
     private boolean mAnimationAllowed;
 
+    private final ArraySet<Uri> mTriggerUris = new ArraySet<Uri>();
+
     @Override
     public void onCreate(Bundle icicle) {
         super.onCreate(icicle);
@@ -199,6 +204,11 @@
         unregisterObserverIfNeeded();
     }
 
+    @Override
+    public void onRefresh(Context context, Uri contentUri) {
+        PartsRefresher.get(context).refreshPart(getPreferenceScreen().getKey());
+    }
+
     public void showLoadingWhenEmpty() {
         View loading = getView().findViewById(R.id.loading_container);
         setEmptyView(loading);
@@ -500,6 +510,13 @@
     }
 
     @Override
+    public void onAttach(Activity activity) {
+        super.onAttach(activity);
+        PartsRefresher.get(activity).addTrigger(this,
+                mTriggerUris.toArray(new Uri[mTriggerUris.size()]));
+    }
+
+    @Override
     public void onDetach() {
         if (isRemoving()) {
             if (mDialogFragment != null) {
@@ -507,9 +524,18 @@
                 mDialogFragment = null;
             }
         }
+        PartsRefresher.get(getActivity()).removeTrigger(this);
         super.onDetach();
     }
 
+    protected void addTrigger(Uri... contentUris) {
+        mTriggerUris.addAll(Arrays.asList(contentUris));
+        if (!isDetached()) {
+            PartsRefresher.get(getActivity()).addTrigger(this,
+                    mTriggerUris.toArray(new Uri[mTriggerUris.size()]));
+        }
+    }
+
     // Dialog management
 
     protected void showDialog(int dialogId) {
@@ -736,14 +762,6 @@
         getActivity().setResult(result);
     }
 
-    public String getDashboardTitle() {
-        return null;
-    }
-
-    public String getDashboardSummary() {
-        return null;
-    }
-
     public boolean isAvailable() {
         return true;
     }
diff --git a/src/org/cyanogenmod/cmparts/search/CMPartsSearchIndexablesProvider.java b/src/org/cyanogenmod/cmparts/search/CMPartsSearchIndexablesProvider.java
index 10f4ea5..4537e32 100644
--- a/src/org/cyanogenmod/cmparts/search/CMPartsSearchIndexablesProvider.java
+++ b/src/org/cyanogenmod/cmparts/search/CMPartsSearchIndexablesProvider.java
@@ -23,6 +23,7 @@
 
 import org.cyanogenmod.cmparts.search.Searchable.SearchIndexProvider;
 import org.cyanogenmod.internal.cmparts.PartInfo;
+import org.cyanogenmod.internal.cmparts.PartsList;
 import org.cyanogenmod.platform.internal.R;
 
 import java.lang.reflect.Field;
@@ -54,8 +55,6 @@
 import static android.provider.SearchIndexablesContract.INDEXABLES_XML_RES_COLUMNS;
 import static android.provider.SearchIndexablesContract.NON_INDEXABLES_KEYS_COLUMNS;
 import static org.cyanogenmod.internal.cmparts.PartsList.CMPARTS_ACTIVITY;
-import static org.cyanogenmod.internal.cmparts.PartsList.getPartInfo;
-import static org.cyanogenmod.internal.cmparts.PartsList.getPartsList;
 
 /**
  * Provides search metadata to the Settings app
@@ -70,12 +69,12 @@
     @Override
     public Cursor queryXmlResources(String[] strings) {
         MatrixCursor cursor = new MatrixCursor(INDEXABLES_XML_RES_COLUMNS);
-        final Set<String> keys = getPartsList(getContext());
+        final Set<String> keys = PartsList.get(getContext()).getPartsList();
 
         // return all of the xml resources listed in the resource: attribute
         // from parts_catalog.xml for indexing
         for (String key : keys) {
-            PartInfo i = getPartInfo(getContext(), key);
+            PartInfo i = PartsList.get(getContext()).getPartInfo(key);
             if (i == null || i.getXmlRes() <= 0) {
                 continue;
             }
@@ -97,12 +96,12 @@
     @Override
     public Cursor queryRawData(String[] strings) {
         MatrixCursor cursor = new MatrixCursor(INDEXABLES_RAW_COLUMNS);
-        final Set<String> keys = getPartsList(getContext());
+        final Set<String> keys = PartsList.get(getContext()).getPartsList();
 
         // we also submit keywords and metadata for all top-level items
         // which don't have an associated XML resource
         for (String key : keys) {
-            PartInfo i = getPartInfo(getContext(), key);
+            PartInfo i = PartsList.get(getContext()).getPartInfo(key);
             if (i == null) {
                 continue;
             }
@@ -155,11 +154,11 @@
     public Cursor queryNonIndexableKeys(String[] strings) {
         MatrixCursor cursor = new MatrixCursor(NON_INDEXABLES_KEYS_COLUMNS);
 
-        final Set<String> keys = getPartsList(getContext());
+        final Set<String> keys = PartsList.get(getContext()).getPartsList();
         final Set<String> nonIndexables = new ArraySet<>();
 
         for (String key : keys) {
-            PartInfo i = getPartInfo(getContext(), key);
+            PartInfo i = PartsList.get(getContext()).getPartInfo(key);
             if (i == null) {
                 continue;
             }