Add Search to the Settings App (initial release)

- add basic UI for search
- build the search Index thru sqlite FTS4 (faster than FTS3)
- create the search Index on the fly depending on the locale
- re-index if there is a configuration change
- re-index too if the Android build version has changed (usefull
for an Android OTA or when a new Android version is pushed as
we need to recompute the Index)
- search thru "title" and "summary" Preference's data
- group results in the same order of the Settings categories
into the Drawer
- rewrite "title" and/or "summary" if they are containing
an hyphen "\u2011"
- add Preference Keywords (only for the Settings App) in the
Index and allow search on them (Wi-Fi network preference is
used as an example)

Known restrictions:

- we cannot yet search for "dynamic settings"
- ... nor we cannot search for settings coming from an external App
(like the Phone App and its related settings that are surfacing
into the Settings App).
- will need a few other CLs to add more keywords (and have them translated)

Change-Id: I017a4d6c433f28c257c08cacc1bed98c4c517039
diff --git a/src/com/android/settings/SettingsActivity.java b/src/com/android/settings/SettingsActivity.java
index fa6f01a..6b7bdc9 100644
--- a/src/com/android/settings/SettingsActivity.java
+++ b/src/com/android/settings/SettingsActivity.java
@@ -94,6 +94,8 @@
 import com.android.settings.deviceinfo.Memory;
 import com.android.settings.deviceinfo.UsbSettings;
 import com.android.settings.fuelgauge.PowerUsageSummary;
+import com.android.settings.indexer.Index;
+import com.android.settings.indexer.IndexableData;
 import com.android.settings.inputmethod.InputMethodAndLanguageSettings;
 import com.android.settings.inputmethod.KeyboardLayoutPickerFragment;
 import com.android.settings.inputmethod.SpellCheckersSettings;
@@ -159,6 +161,8 @@
      */
     public static final String EXTRA_NO_HEADERS = ":settings:no_headers";
 
+    public static final String BACK_STACK_PREFS = ":settings:prefs";
+
     // extras that allow any preference activity to be launched as part of a wizard
 
     // show Back and Next buttons? takes boolean parameter
@@ -180,8 +184,6 @@
      */
     protected static final String EXTRA_SHOW_FRAGMENT_TITLE = ":settings:show_fragment_title";
 
-    private static final String BACK_STACK_PREFS = ":settings:prefs";
-
     private static final String META_DATA_KEY_HEADER_ID =
         "com.android.settings.TOP_LEVEL_HEADER_ID";
 
@@ -340,6 +342,71 @@
         }
     };
 
+    /**
+     * Searchable data description.
+     *
+     * Known restriction: we are only searching (for now) the first level of Settings.
+     */
+    private static IndexableData[] INDEXABLE_DATA = new IndexableData[] {
+            new IndexableData(1, R.xml.wifi_settings,
+                    "com.android.settings.wifi.WifiSettings",
+                    R.drawable.ic_settings_wireless),
+            new IndexableData(2, R.xml.bluetooth_settings,
+                    "com.android.settings.bluetooth.BluetoothSettings",
+                    R.drawable.ic_settings_bluetooth2),
+            new IndexableData(3, R.xml.data_usage_metered_prefs,
+                    "com.android.settings.net.DataUsageMeteredSettings",
+                    R.drawable.ic_settings_data_usage),
+            new IndexableData(4, R.xml.wireless_settings,
+                    "com.android.settings.WirelessSettings",
+                    R.drawable.empty_icon),
+            new IndexableData(5, R.xml.home_selection,
+                    "com.android.settings.HomeSettings",
+                    R.drawable.ic_settings_home),
+            new IndexableData(6, R.xml.sound_settings,
+                    "com.android.settings.SoundSettings",
+                    R.drawable.ic_settings_sound),
+            new IndexableData(7, R.xml.display_settings,
+                    "com.android.settings.DisplaySettings",
+                    R.drawable.ic_settings_display),
+            new IndexableData(8, R.xml.device_info_memory,
+                    "com.android.settings.deviceinfo.Memory",
+                    R.drawable.ic_settings_storage),
+            new IndexableData(9, R.xml.power_usage_summary,
+                    "com.android.settings.fuelgauge.PowerUsageSummary",
+                    R.drawable.ic_settings_battery),
+            new IndexableData(10, R.xml.user_settings,
+                    "com.android.settings.users.UserSettings",
+                    R.drawable.ic_settings_multiuser),
+            new IndexableData(11, R.xml.location_settings,
+                    "com.android.settings.location.LocationSettings",
+                    R.drawable.ic_settings_location),
+            new IndexableData(12, R.xml.security_settings,
+                    "com.android.settings.SecuritySettings",
+                    R.drawable.ic_settings_security),
+            new IndexableData(13, R.xml.language_settings,
+                    "com.android.settings.inputmethod.InputMethodAndLanguageSettings",
+                    R.drawable.ic_settings_language),
+            new IndexableData(14, R.xml.privacy_settings,
+                    "com.android.settings.PrivacySettings",
+                    R.drawable.ic_settings_backup),
+            new IndexableData(15, R.xml.date_time_prefs,
+                    "com.android.settings.DateTimeSettings",
+                    R.drawable.ic_settings_date_time),
+            new IndexableData(16, R.xml.accessibility_settings,
+                    "com.android.settings.accessibility.AccessibilitySettings",
+                    R.drawable.ic_settings_accessibility),
+            new IndexableData(17, R.xml.print_settings,
+                    "com.android.settings.print.PrintSettingsFragment",
+                    com.android.internal.R.drawable.ic_print),
+            new IndexableData(18, R.xml.development_prefs,
+                    "com.android.settings.DevelopmentSettings",
+                    R.drawable.ic_settings_development),
+            new IndexableData(19, R.xml.device_info_settings,
+                    "com.android.settings.DeviceInfoSettings",
+                    R.drawable.ic_settings_about),
+    };
+
     @Override
     public boolean onPreferenceStartFragment(PreferenceFragment caller, Preference pref) {
         // Override the fragment title for Wallpaper settings
@@ -463,6 +530,7 @@
     public void onConfigurationChanged(Configuration newConfig) {
         super.onConfigurationChanged(newConfig);
         mDrawerToggle.onConfigurationChanged(newConfig);
+        Index.getInstance(this).update();
     }
 
     @Override
@@ -479,6 +547,9 @@
             getWindow().setUiOptions(getIntent().getIntExtra(EXTRA_UI_OPTIONS, 0));
         }
 
+        Index.getInstance(this).addIndexableData(INDEXABLE_DATA);
+        Index.getInstance(this).update();
+
         mAuthenticatorHelper = new AuthenticatorHelper();
         mAuthenticatorHelper.updateAuthDescriptions(this);
         mAuthenticatorHelper.onAccountsUpdated(this, null);
diff --git a/src/com/android/settings/dashboard/DashboardSummary.java b/src/com/android/settings/dashboard/DashboardSummary.java
index f5b47ae..ce3b0c0 100644
--- a/src/com/android/settings/dashboard/DashboardSummary.java
+++ b/src/com/android/settings/dashboard/DashboardSummary.java
@@ -17,17 +17,97 @@
 package com.android.settings.dashboard;
 
 import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.AsyncTask;
 import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AdapterView;
+import android.widget.BaseAdapter;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.TextView;
 import com.android.settings.R;
+import com.android.settings.SettingsActivity;
+import com.android.settings.indexer.Index;
 
 public class DashboardSummary extends Fragment {
 
+    private static final String SAVE_KEY_QUERY = ":settings:query";
+
+    private EditText mEditText;
+    private ListView mListView;
+
+    private SearchResultsAdapter mAdapter;
+    private Index mIndex;
+    private UpdateSearchResultsTask mUpdateSearchResultsTask;
+
+    /**
+     * A basic AsyncTask for updating the query results cursor
+     */
+    private class UpdateSearchResultsTask extends AsyncTask<String, Void, Cursor> {
+        @Override
+        protected Cursor doInBackground(String... params) {
+            return mIndex.search(params[0]);
+        }
+
+        @Override
+        protected void onPostExecute(Cursor cursor) {
+            if (!isCancelled()) {
+                setCursor(cursor);
+            } else if (cursor != null) {
+                cursor.close();
+            }
+        }
+    }
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
+
+        mIndex = Index.getInstance(getActivity());
+        mAdapter = new SearchResultsAdapter(getActivity());
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+
+        clearResults();
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+
+        updateSearchResults();
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        if (outState != null) {
+            outState.putString(SAVE_KEY_QUERY, mEditText.getText().toString());
+        }
+    }
+
+    @Override
+    public void onActivityCreated(Bundle savedInstanceState) {
+        super.onActivityCreated(savedInstanceState);
+
+        if (savedInstanceState != null) {
+            final String query = savedInstanceState.getString(SAVE_KEY_QUERY);
+            if (query != null && !TextUtils.isEmpty(query)) {
+                mEditText.setText(query);
+            }
+        }
     }
 
     @Override
@@ -36,6 +116,185 @@
 
         final View view = inflater.inflate(R.layout.dashboard, container, false);
 
+        mEditText = (EditText)view.findViewById(R.id.edittext_query);
+        mEditText.addTextChangedListener(new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+            }
+
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+                updateSearchResults();
+            }
+
+            @Override
+            public void afterTextChanged(Editable s) {
+            }
+        });
+        mEditText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+            @Override
+            public void onFocusChange(View v, boolean hasFocus) {
+                if (!hasFocus) {
+                    closeSoftKeyboard();
+                }
+            }
+        });
+
+        mListView = (ListView) view.findViewById(R.id.list_results);
+        mListView.setAdapter(mAdapter);
+        mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+            @Override
+            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+                closeSoftKeyboard();
+                final Cursor cursor = mAdapter.mCursor;
+                cursor.moveToPosition(position);
+                final String fragmentName = cursor.getString(Index.COLUMN_INDEX_FRAGMENT_NAME);
+                final String fragmentTitle = cursor.getString(Index.COLUMN_INDEX_FRAGMENT_TITLE);
+
+                ((SettingsActivity) getActivity()).startPreferencePanel(fragmentName, null, 0,
+                        fragmentTitle, null, 0);
+            }
+        });
+
         return view;
     }
+
+    private void closeSoftKeyboard() {
+        InputMethodManager imm = InputMethodManager.peekInstance();
+        if (imm != null && imm.isActive(mEditText)) {
+            imm.hideSoftInputFromWindow(mEditText.getWindowToken(), 0);
+        }
+    }
+
+    private void clearResults() {
+        if (mUpdateSearchResultsTask != null) {
+            mUpdateSearchResultsTask.cancel(false);
+            mUpdateSearchResultsTask = null;
+        }
+        setCursor(null);
+    }
+
+    private void setCursor(Cursor cursor) {
+        Cursor oldCursor = mAdapter.swapCursor(cursor);
+        if (oldCursor != null) {
+            oldCursor.close();
+        }
+    }
+
+    private void updateSearchResults() {
+        if (mUpdateSearchResultsTask != null) {
+            mUpdateSearchResultsTask.cancel(false);
+            mUpdateSearchResultsTask = null;
+        }
+        final String query = mEditText.getText().toString();
+        if (TextUtils.isEmpty(query)) {
+            setCursor(null);
+        } else {
+            mUpdateSearchResultsTask = new UpdateSearchResultsTask();
+            mUpdateSearchResultsTask.execute(query);
+        }
+    }
+
+    private static class SearchResult {
+        public String title;
+        public String summary;
+        public int iconResId;
+
+        public SearchResult(String title, String summary, int iconResId) {
+            this.title = title;
+            this.summary = summary;
+            this.iconResId = iconResId;
+        }
+    }
+
+    private static class SearchResultsAdapter extends BaseAdapter {
+
+        private Cursor mCursor;
+        private LayoutInflater mInflater;
+        private boolean mDataValid;
+
+        public SearchResultsAdapter(Context context) {
+            mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            mDataValid = false;
+        }
+
+        public Cursor swapCursor(Cursor newCursor) {
+            if (newCursor == mCursor) {
+                return null;
+            }
+            Cursor oldCursor = mCursor;
+            mCursor = newCursor;
+            if (newCursor != null) {
+                mDataValid = true;
+                notifyDataSetChanged();
+            } else {
+                mDataValid = false;
+                notifyDataSetInvalidated();
+            }
+            return oldCursor;
+        }
+
+        @Override
+        public int getCount() {
+            if (!mDataValid || mCursor == null || mCursor.isClosed()) return 0;
+            return mCursor.getCount();
+        }
+
+        @Override
+        public Object getItem(int position) {
+            if (mDataValid && mCursor.moveToPosition(position)) {
+                final String title = mCursor.getString(Index.COLUMN_INDEX_TITLE);
+                final String summary = mCursor.getString(Index.COLUMN_INDEX_SUMMARY);
+                final String iconResStr = mCursor.getString(Index.COLUMN_INDEX_ICON);
+                final int iconResId =
+                        TextUtils.isEmpty(iconResStr) ? 0 : Integer.parseInt(iconResStr);
+                return new SearchResult(title, summary, iconResId);
+            }
+            return null;
+        }
+
+        @Override
+        public long getItemId(int position) {
+            return 0;
+        }
+
+        @Override
+        public View getView(int position, View convertView, ViewGroup parent) {
+            if (!mDataValid && convertView == null) {
+                throw new IllegalStateException(
+                        "this should only be called when the cursor is valid");
+            }
+            if (!mCursor.moveToPosition(position)) {
+                throw new IllegalStateException("couldn't move cursor to position " + position);
+            }
+
+            View view;
+            TextView textTitle;
+            TextView textSummary;
+            ImageView imageView;
+
+            if (convertView == null) {
+                view = mInflater.inflate(R.layout.search_result, parent, false);
+            } else {
+                view = convertView;
+            }
+            textTitle = (TextView) view.findViewById(R.id.title);
+            textSummary = (TextView) view.findViewById(R.id.summary);
+            imageView = (ImageView) view.findViewById(R.id.icon);
+
+            SearchResult result = (SearchResult) getItem(position);
+
+            textTitle.setText(result.title);
+            textSummary.setText(result.summary);
+            if (result.iconResId != R.drawable.empty_icon) {
+                imageView.setImageResource(result.iconResId);
+                imageView.setBackgroundResource(R.color.background_search_result_icon);
+            } else {
+                imageView.setImageDrawable(null);
+                imageView.setBackgroundResource(R.drawable.empty_icon);
+            }
+
+            return view;
+        }
+    }
 }
diff --git a/src/com/android/settings/indexer/Index.java b/src/com/android/settings/indexer/Index.java
new file mode 100644
index 0000000..1a2eb48
--- /dev/null
+++ b/src/com/android/settings/indexer/Index.java
@@ -0,0 +1,379 @@
+/*
+ * Copyright (C) 2014 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.indexer;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.TypedValue;
+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.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static com.android.settings.indexer.IndexDatabaseHelper.Tables;
+import static com.android.settings.indexer.IndexDatabaseHelper.IndexColumns;
+
+public class Index {
+
+    private static final String LOG_TAG = "Indexer";
+
+    // Those indices should match the indices of SELECT_COLUMNS !
+    public static final int COLUMN_INDEX_TITLE = 1;
+    public static final int COLUMN_INDEX_SUMMARY = 2;
+    public static final int COLUMN_INDEX_FRAGMENT_NAME = 4;
+    public static final int COLUMN_INDEX_FRAGMENT_TITLE = 5;
+    public static final int COLUMN_INDEX_ICON = 7;
+
+    // If you change the order of columns here, you SHOULD change the COLUMN_INDEX_XXX values
+    private static final String[] SELECT_COLUMNS = new String[] {
+            IndexColumns.DATA_RANK,
+            IndexColumns.DATA_TITLE,
+            IndexColumns.DATA_SUMMARY,
+            IndexColumns.DATA_KEYWORDS,
+            IndexColumns.FRAGMENT_NAME,
+            IndexColumns.FRAGMENT_TITLE,
+            IndexColumns.INTENT,
+            IndexColumns.ICON
+    };
+
+    private static final String EMPTY = "";
+    private static final String NON_BREAKING_HYPHEN = "\u2011";
+    private static final String HYPHEN = "-";
+
+    private static Index sInstance;
+
+    private final AtomicBoolean mIsAvailable = new AtomicBoolean(false);
+    private final List<IndexableData> mDataToIndex = new ArrayList<IndexableData>();
+
+    private final Context mContext;
+
+    /**
+     * A basic singleton
+     */
+    public static Index getInstance(Context context) {
+        if (sInstance == null) {
+            sInstance = new Index(context);
+        }
+        return sInstance;
+    }
+
+    public Index(Context context) {
+        mContext = context;
+    }
+
+    public boolean isAvailable() {
+        return mIsAvailable.get();
+    }
+
+    public Cursor search(String query) {
+        return getReadableDatabase().rawQuery(buildSQL(query), null);
+    }
+
+    private String buildSQL(String query) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(buildSQLForColumn(query, IndexColumns.DATA_TITLE));
+        sb.append(" UNION ");
+        sb.append(buildSQLForColumn(query, IndexColumns.DATA_SUMMARY));
+        sb.append(" UNION ");
+        sb.append(buildSQLForColumn(query, IndexColumns.DATA_KEYWORDS));
+        sb.append(" ORDER BY ");
+        sb.append(IndexColumns.DATA_RANK);
+        return sb.toString();
+    }
+
+    private String buildSQLForColumn(String query, String columnName) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("SELECT ");
+        for (int n = 0; n < SELECT_COLUMNS.length; n++) {
+            sb.append(SELECT_COLUMNS[n]);
+            if (n < SELECT_COLUMNS.length - 1) {
+                sb.append(", ");
+            }
+        }
+        sb.append(" FROM ");
+        sb.append(Tables.TABLE_PREFS_INDEX);
+        sb.append(" WHERE ");
+        sb.append(buildWhereStringForColumn(query, columnName));
+
+        return sb.toString();
+    }
+
+    private String buildWhereStringForColumn(String query, String columnName) {
+        final StringBuilder sb = new StringBuilder(columnName);
+        sb.append(" MATCH ");
+        DatabaseUtils.appendEscapedSQLString(sb, query + "*");
+        sb.append(" AND ");
+        sb.append(IndexColumns.LOCALE);
+        sb.append(" = ");
+        DatabaseUtils.appendEscapedSQLString(sb, Locale.getDefault().toString());
+        return sb.toString();
+    }
+
+    public void addIndexableData(IndexableData data) {
+        mDataToIndex.add(data);
+    }
+
+    public void addIndexableData(IndexableData[] array) {
+        final int count = array.length;
+        for (int n = 0; n < count; n++) {
+            addIndexableData(array[n]);
+        }
+    }
+
+    public boolean update() {
+        final IndexTask task = new IndexTask();
+        task.execute();
+        try {
+            return task.get();
+        } catch (InterruptedException e) {
+            Log.e(LOG_TAG, "Cannot update index: " + e.getMessage());
+            return false;
+        } catch (ExecutionException e) {
+            Log.e(LOG_TAG, "Cannot update index: " + e.getMessage());
+            return false;
+        }
+    }
+
+    private SQLiteDatabase getReadableDatabase() {
+        return IndexDatabaseHelper.getInstance(mContext).getReadableDatabase();
+    }
+
+    private SQLiteDatabase getWritableDatabase() {
+        return IndexDatabaseHelper.getInstance(mContext).getWritableDatabase();
+    }
+
+    /**
+     * A private class for updating the Index database
+     */
+    private class IndexTask extends AsyncTask<Void, Integer, Boolean> {
+
+        @Override
+        protected void onPreExecute() {
+            super.onPreExecute();
+            mIsAvailable.set(false);
+        }
+
+        @Override
+        protected void onPostExecute(Boolean aBoolean) {
+            super.onPostExecute(aBoolean);
+            mIsAvailable.set(true);
+        }
+
+        @Override
+        protected Boolean doInBackground(Void... params) {
+            final SQLiteDatabase database = getWritableDatabase();
+            boolean result = false;
+            final Locale locale = Locale.getDefault();
+            final String localeStr = locale.toString();
+            if (isLocaleAlreadyIndexed(database, locale)) {
+                Log.d(LOG_TAG, "Locale '" + localeStr + "' is already indexed");
+                return true;
+            }
+            final long current = System.currentTimeMillis();
+            try {
+                database.beginTransaction();
+                final int count = mDataToIndex.size();
+                for (int n = 0; n < count; n++) {
+                    final IndexableData data = mDataToIndex.get(n);
+                    indexFromResource(database, locale, data.xmlResId, data.fragmentName,
+                            data.iconResId, data.rank);
+                }
+                database.setTransactionSuccessful();
+                result = true;
+            } finally {
+                database.endTransaction();
+            }
+            final long now = System.currentTimeMillis();
+            Log.d(LOG_TAG, "Indexing locale '" + localeStr + "' took " +
+                    (now - current) + " millis");
+            return result;
+        }
+
+        private boolean isLocaleAlreadyIndexed(SQLiteDatabase database, Locale locale) {
+            Cursor cursor = null;
+            boolean result = false;
+            final StringBuilder sb = new StringBuilder(IndexColumns.LOCALE);
+            sb.append(" = ");
+            DatabaseUtils.appendEscapedSQLString(sb, locale.toString());
+            try {
+                // We care only for 1 row
+                cursor = database.query(Tables.TABLE_PREFS_INDEX, null,
+                        sb.toString(), null, null, null, null, "1");
+                final int count = cursor.getCount();
+                result = (count >= 1);
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+            }
+            return result;
+        }
+
+        private void indexFromResource(SQLiteDatabase database, Locale locale, int xmlResId,
+                String fragmentName, int iconResId, int rank) {
+            XmlResourceParser parser = null;
+            final String localeStr = locale.toString();
+            try {
+                parser = mContext.getResources().getXml(xmlResId);
+
+                int type;
+                while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
+                        && type != XmlPullParser.START_TAG) {
+                    // Parse next until start tag is found
+                }
+
+                String nodeName = parser.getName();
+                if (!"PreferenceScreen".equals(nodeName)) {
+                    throw new RuntimeException(
+                            "XML document must start with <PreferenceScreen> tag; found"
+                                    + nodeName + " at " + parser.getPositionDescription());
+                }
+
+                final int outerDepth = parser.getDepth();
+                final AttributeSet attrs = Xml.asAttributeSet(parser);
+                final String fragmentTitle = getData(attrs,
+                        com.android.internal.R.styleable.Preference, com.android.internal.R.styleable.Preference_title);
+
+                String title = getDataTitle(attrs);
+                String summary = getDataSummary(attrs);
+                String keywords = getDataKeywords(attrs);
+
+                // Insert rows for the main PreferenceScreen node. Rewrite the data for removing
+                // hyphens.
+                inserOneRowWithFilteredData(database, localeStr, title, summary, fragmentName,
+                        fragmentTitle, iconResId, rank, keywords, "\u2011", "");
+                inserOneRowWithFilteredData(database, localeStr, title, summary, fragmentName,
+                        fragmentTitle, iconResId, rank, keywords, "\u2011", "-");
+
+                while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
+                        && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
+                    if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {
+                        continue;
+                    }
+
+                    title = getDataTitle(attrs);
+                    summary = getDataSummary(attrs);
+                    keywords = getDataKeywords(attrs);
+
+                    // Insert rows for the child nodes of PreferenceScreen
+                    inserOneRowWithFilteredData(database, localeStr, title, summary, fragmentName,
+                            fragmentTitle, iconResId, rank, keywords, NON_BREAKING_HYPHEN, EMPTY);
+                    inserOneRowWithFilteredData(database, localeStr, title, summary, fragmentName,
+                            fragmentTitle, iconResId, rank, keywords, NON_BREAKING_HYPHEN, HYPHEN);
+                }
+
+            } catch (XmlPullParserException e) {
+                throw new RuntimeException("Error parsing PreferenceScreen", e);
+            } catch (IOException e) {
+                throw new RuntimeException("Error parsing PreferenceScreen", e);
+            } finally {
+                if (parser != null) parser.close();
+            }
+        }
+
+        private void inserOneRowWithFilteredData(SQLiteDatabase database, String locale,
+                String title, String summary, String fragmentName, String fragmentTitle,
+                int iconResId, int rank, String keywords, String seq, String replacement) {
+
+            String updatedTitle;
+            String updateSummary;
+            if (title != null && title.contains(seq)) {
+                updatedTitle = title.replaceAll(seq, replacement);
+            } else {
+                updatedTitle = title;
+            }
+            if (summary != null && summary.contains(seq)) {
+                updateSummary = summary.replaceAll(seq, replacement);
+            } else {
+                updateSummary = summary;
+            }
+            insertOneRow(database, locale,
+                    updatedTitle, updateSummary,
+                    fragmentName, fragmentTitle, iconResId, rank, keywords);
+        }
+
+        private void insertOneRow(SQLiteDatabase database, String locale, String title,
+                                  String summary, String fragmentName, String fragmentTitle,
+                                  int iconResId, int rank, String keywords) {
+
+            if (TextUtils.isEmpty(title)) {
+                return;
+            }
+            ContentValues values = new ContentValues();
+            values.put(IndexColumns.LOCALE, locale);
+            values.put(IndexColumns.DATA_RANK, rank);
+            values.put(IndexColumns.DATA_TITLE, title);
+            values.put(IndexColumns.DATA_SUMMARY, summary);
+            values.put(IndexColumns.DATA_KEYWORDS, keywords);
+            values.put(IndexColumns.FRAGMENT_NAME, fragmentName);
+            values.put(IndexColumns.FRAGMENT_TITLE, fragmentTitle);
+            values.put(IndexColumns.INTENT, "");
+            values.put(IndexColumns.ICON, iconResId);
+
+            database.insertOrThrow(Tables.TABLE_PREFS_INDEX, null, values);
+        }
+
+        private String getDataTitle(AttributeSet attrs) {
+            return getData(attrs,
+                    com.android.internal.R.styleable.Preference,
+                    com.android.internal.R.styleable.Preference_title);
+        }
+
+        private String getDataSummary(AttributeSet attrs) {
+            return getData(attrs,
+                    com.android.internal.R.styleable.Preference,
+                    com.android.internal.R.styleable.Preference_summary);
+        }
+
+        private String getDataKeywords(AttributeSet attrs) {
+            return getData(attrs,
+                    R.styleable.Preference,
+                    R.styleable.Preference_keywords);
+        }
+
+        private String getData(AttributeSet set, int[] attrs, int resId) {
+            final TypedArray sa = mContext.obtainStyledAttributes(set, attrs);
+            final TypedValue tv = sa.peekValue(resId);
+
+            CharSequence data = null;
+            if (tv != null && tv.type == TypedValue.TYPE_STRING) {
+                if (tv.resourceId != 0) {
+                    data = mContext.getText(tv.resourceId);
+                } else {
+                    data = tv.string;
+                }
+            }
+            return (data != null) ? data.toString() : null;
+        }
+    }
+}
diff --git a/src/com/android/settings/indexer/IndexDatabaseHelper.java b/src/com/android/settings/indexer/IndexDatabaseHelper.java
new file mode 100644
index 0000000..243f7b8
--- /dev/null
+++ b/src/com/android/settings/indexer/IndexDatabaseHelper.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2014 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.indexer;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.os.Build;
+import android.util.Log;
+
+public class IndexDatabaseHelper extends SQLiteOpenHelper {
+
+    private static final String TAG = "IndexDatabaseHelper";
+
+    private static final String DATABASE_NAME = "search_index.db";
+    private static final int DATABASE_VERSION = 100;
+
+    public interface Tables {
+        public static final String TABLE_PREFS_INDEX = "prefs_index";
+        public static final String TABLE_META_INDEX = "meta_index";
+    }
+
+    public interface IndexColumns {
+        public static final String LOCALE = "locale";
+        public static final String DATA_RANK = "data_rank";
+        public static final String DATA_TITLE = "data_title";
+        public static final String DATA_SUMMARY = "data_summary";
+        public static final String DATA_KEYWORDS = "data_keywords";
+        public static final String FRAGMENT_NAME = "fragment_name";
+        public static final String FRAGMENT_TITLE = "fragment_title";
+        public static final String INTENT = "intent";
+        public static final String ICON = "icon";
+    }
+
+    public interface MetaColumns {
+        public static final String BUILD = "build";
+    }
+
+    private static final String CREATE_INDEX_TABLE =
+            "CREATE VIRTUAL TABLE " + Tables.TABLE_PREFS_INDEX + " USING fts4" +
+                    "(" +
+                    IndexColumns.LOCALE +
+                    ", " +
+                    IndexColumns.DATA_RANK +
+                    ", " +
+                    IndexColumns.DATA_TITLE +
+                    ", " +
+                    IndexColumns.DATA_SUMMARY +
+                    ", " +
+                    IndexColumns.DATA_KEYWORDS +
+                    ", " +
+                    IndexColumns.FRAGMENT_NAME +
+                    ", " +
+                    IndexColumns.FRAGMENT_TITLE +
+                    ", " +
+                    IndexColumns.INTENT +
+                    ", " +
+                    IndexColumns.ICON +
+                    ");";
+
+    private static final String CREATE_META_TABLE =
+            "CREATE TABLE " + Tables.TABLE_META_INDEX +
+                    "(" +
+                    MetaColumns.BUILD + " VARCHAR(32) NOT NULL" +
+                    ")";
+
+    private static final String INSERT_BUILD_VERSION =
+            "INSERT INTO " + Tables.TABLE_META_INDEX +
+                    " VALUES ('" + Build.VERSION.INCREMENTAL + "');";
+
+    private static final String SELECT_BUILD_VERSION =
+            "SELECT " + MetaColumns.BUILD + " FROM " + Tables.TABLE_META_INDEX + " LIMIT 1;";
+
+    private static IndexDatabaseHelper sSingleton;
+
+    public static synchronized IndexDatabaseHelper getInstance(Context context) {
+        if (sSingleton == null) {
+            sSingleton = new IndexDatabaseHelper(context);
+        }
+        return sSingleton;
+    }
+
+    public IndexDatabaseHelper(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        bootstrapDB(db);
+    }
+
+    private void bootstrapDB(SQLiteDatabase db) {
+        db.execSQL(CREATE_INDEX_TABLE);
+        db.execSQL(CREATE_META_TABLE);
+        db.execSQL(INSERT_BUILD_VERSION);
+        Log.i(TAG, "Bootstrapped database");
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+    }
+
+    private String getBuildVersion(SQLiteDatabase db) {
+        String version = null;
+        Cursor cursor = null;
+        try {
+            cursor = db.rawQuery(SELECT_BUILD_VERSION, null);
+            if (cursor.moveToFirst()) {
+                version = cursor.getString(0);
+            }
+        }
+        catch (Exception e) {
+            Log.e(TAG, "Cannot get build version from Index metadata");
+        }
+        finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+        return version;
+    }
+
+    private void dropTables(SQLiteDatabase db) {
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.TABLE_META_INDEX);
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.TABLE_PREFS_INDEX);
+    }
+
+    @Override
+    public void onOpen(SQLiteDatabase db) {
+        super.onOpen(db);
+
+        if (!Build.VERSION.INCREMENTAL.equals(getBuildVersion(db))) {
+            Log.w(TAG, "Index needs to be rebuilt");
+            // We need to drop the tables and recreate them
+            dropTables(db);
+            bootstrapDB(db);
+        } else {
+            Log.i(TAG, "Index is fine");
+        }
+    }
+}
diff --git a/src/com/android/settings/indexer/IndexableData.java b/src/com/android/settings/indexer/IndexableData.java
new file mode 100644
index 0000000..61714a2
--- /dev/null
+++ b/src/com/android/settings/indexer/IndexableData.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2014 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.indexer;
+
+public class IndexableData {
+
+    public int rank;
+    public int xmlResId;
+    public String fragmentName;
+    public int iconResId;
+
+    public IndexableData(int rank, int dataResId, String name, int iconResId) {
+        this.rank = rank;
+        this.xmlResId = dataResId;
+        this.fragmentName = name;
+        this.iconResId = iconResId;
+    }
+}
\ No newline at end of file