SearchActivity.java revision c27b5dc04d496dc4176bea034498bec4e68045fa
1/**
2 * Copyright (c) 2009, Google Inc.
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.mms.ui;
18
19import java.util.HashMap;
20import java.util.regex.Matcher;
21import java.util.regex.Pattern;
22
23import com.android.mms.MmsApp;
24import com.android.mms.R;
25import android.app.ListActivity;
26import android.app.SearchManager;
27import android.content.AsyncQueryHandler;
28import android.content.ContentResolver;
29import android.content.Context;
30import android.content.Intent;
31import android.database.Cursor;
32import android.graphics.Color;
33import android.graphics.Typeface;
34import android.net.Uri;
35import android.os.Bundle;
36import android.provider.SearchRecentSuggestions;
37
38import android.provider.Telephony;
39import android.text.SpannableString;
40import android.text.TextPaint;
41import android.text.style.ForegroundColorSpan;
42import android.text.style.StyleSpan;
43import android.util.AttributeSet;
44import android.view.LayoutInflater;
45import android.view.View;
46import android.view.ViewGroup;
47import android.widget.CursorAdapter;
48import android.widget.ListView;
49import android.widget.TextView;
50
51import com.android.mms.data.Contact;
52import com.android.mms.data.Contact.UpdateListener;
53import com.android.mms.ui.ComposeMessageActivity;
54
55/***
56 * Presents a List of search results.  Each item in the list represents a thread which
57 * matches.  The item contains the contact (or phone number) as the "title" and a
58 * snippet of what matches, below.  The snippet is taken from the most recent part of
59 * the conversation that has a match.  Each match within the visible portion of the
60 * snippet is highlighted.
61 */
62
63public class SearchActivity extends ListActivity
64{
65    private AsyncQueryHandler mQueryHandler;
66
67    // Track which TextView's show which Contact objects so that we can update
68    // appropriately when the Contact gets fully loaded.
69    private HashMap<Contact, TextView> mContactMap = new HashMap<Contact, TextView>();
70
71
72    /*
73     * Subclass of TextView which displays a snippet of text which matches the full text and
74     * highlights the matches within the snippet.
75     */
76    public static class TextViewSnippet extends TextView {
77        private static String sEllipsis = "\u2026";
78
79        private static int sTypefaceHighlight = Typeface.BOLD;
80
81        private String mFullText;
82        private String mTargetString;
83        private Pattern mPattern;
84
85        public TextViewSnippet(Context context, AttributeSet attrs) {
86            super(context, attrs);
87        }
88
89        public TextViewSnippet(Context context) {
90            super(context);
91        }
92
93        public TextViewSnippet(Context context, AttributeSet attrs, int defStyle) {
94            super(context, attrs, defStyle);
95        }
96
97        /**
98         * We have to know our width before we can compute the snippet string.  Do that
99         * here and then defer to super for whatever work is normally done.
100         */
101        @Override
102        protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
103            String fullTextLower = mFullText.toLowerCase();
104            String targetStringLower = mTargetString.toLowerCase();
105
106            int startPos = 0;
107            int searchStringLength = targetStringLower.length();
108            int bodyLength = fullTextLower.length();
109
110            Matcher m = mPattern.matcher(mFullText);
111            if (m.find(0)) {
112                startPos = m.start();
113            }
114
115            TextPaint tp = getPaint();
116
117            float searchStringWidth = tp.measureText(mTargetString);
118            float textFieldWidth = getWidth();
119
120            String snippetString = null;
121            if (searchStringWidth > textFieldWidth) {
122                snippetString = mFullText.substring(startPos, startPos + searchStringLength);
123            } else {
124                float ellipsisWidth = tp.measureText(sEllipsis);
125                textFieldWidth -= (2F * ellipsisWidth); // assume we'll need one on both ends
126
127                int offset = -1;
128                int start = -1;
129                int end = -1;
130                /* TODO: this code could be made more efficient by only measuring the additional
131                 * characters as we widen the string rather than measuring the whole new
132                 * string each time.
133                 */
134                while (true) {
135                    offset += 1;
136
137                    int newstart = Math.max(0, startPos - offset);
138                    int newend = Math.min(bodyLength, startPos + searchStringLength + offset);
139
140                    if (newstart == start && newend == end) {
141                        // if we couldn't expand out any further then we're done
142                        break;
143                    }
144                    start = newstart;
145                    end = newend;
146
147                    // pull the candidate string out of the full text rather than body
148                    // because body has been toLower()'ed
149                    String candidate = mFullText.substring(start, end);
150                    if (tp.measureText(candidate) > textFieldWidth) {
151                        // if the newly computed width would exceed our bounds then we're done
152                        // do not use this "candidate"
153                        break;
154                    }
155
156                    snippetString = String.format(
157                            "%s%s%s",
158                            start == 0 ? "" : sEllipsis,
159                            candidate,
160                            end == bodyLength ? "" : sEllipsis);
161                }
162            }
163
164            SpannableString spannable = new SpannableString(snippetString);
165            int start = 0;
166
167            m = mPattern.matcher(snippetString);
168            while (m.find(start)) {
169                spannable.setSpan(new StyleSpan(sTypefaceHighlight), m.start(), m.end(), 0);
170                start = m.end();
171            }
172            setText(spannable);
173
174            // do this after the call to setText() above
175            super.onLayout(changed, left, top, right, bottom);
176        }
177
178        public void setText(String fullText, String target) {
179            // Use a regular expression to locate the target string
180            // within the full text.  The target string must be
181            // found as a word start so we use \b which matches
182            // word boundaries.
183            String patternString = "\\b" + Pattern.quote(target);
184            mPattern = Pattern.compile(patternString, Pattern.CASE_INSENSITIVE);
185
186            mFullText = fullText;
187            mTargetString = target;
188            requestLayout();
189        }
190    }
191
192    Contact.UpdateListener mContactListener = new Contact.UpdateListener() {
193        public void onUpdate(Contact updated) {
194            TextView tv = mContactMap.get(updated);
195            if (tv != null) {
196                tv.setText(updated.getNameAndNumber());
197            }
198        }
199    };
200
201    @Override
202    public void onStop() {
203        super.onStop();
204        Contact.removeListener(mContactListener);
205    }
206
207    private long getThreadId(long sourceId, long which) {
208        Uri.Builder b = Uri.parse("content://mms-sms/messageIdToThread").buildUpon();
209        b = b.appendQueryParameter("row_id", String.valueOf(sourceId));
210        b = b.appendQueryParameter("table_to_use", String.valueOf(which));
211        String s = b.build().toString();
212
213        Cursor c = getContentResolver().query(
214                Uri.parse(s),
215                null,
216                null,
217                null,
218                null);
219        if (c != null) {
220            try {
221                if (c.moveToFirst()) {
222                    return c.getLong(c.getColumnIndex("thread_id"));
223                }
224            } finally {
225                c.close();
226            }
227        }
228        return -1;
229    }
230
231    @Override
232    public void onCreate(Bundle icicle) {
233        super.onCreate(icicle);
234
235        String searchStringParameter = getIntent().getStringExtra(SearchManager.QUERY);
236        if (searchStringParameter == null) {
237            searchStringParameter = getIntent().getStringExtra("intent_extra_data_key" /*SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA*/);
238        }
239        final String searchString =
240            searchStringParameter != null ? searchStringParameter.trim() : searchStringParameter;
241
242        // If we're being launched with a source_id then just go to that particular thread.
243        // Work around the fact that suggestions can only launch the search activity, not some
244        // arbitrary activity (such as ComposeMessageActivity).
245        final Uri u = getIntent().getData();
246        if (u != null && u.getQueryParameter("source_id") != null) {
247            Thread t = new Thread(new Runnable() {
248                public void run() {
249                    try {
250                        long sourceId = Long.parseLong(u.getQueryParameter("source_id"));
251                        long whichTable = Long.parseLong(u.getQueryParameter("which_table"));
252                        long threadId = getThreadId(sourceId, whichTable);
253
254                        final Intent onClickIntent = new Intent(SearchActivity.this, ComposeMessageActivity.class);
255                        onClickIntent.putExtra("highlight", searchString);
256                        onClickIntent.putExtra("select_id", sourceId);
257                        onClickIntent.putExtra("thread_id", threadId);
258                        startActivity(onClickIntent);
259                        finish();
260                        return;
261                    } catch (NumberFormatException ex) {
262                        // ok, we do not have a thread id so continue
263                    }
264                }
265            });
266            t.start();
267            return;
268        }
269
270        setContentView(R.layout.search_activity);
271        ContentResolver cr = getContentResolver();
272
273        searchStringParameter = searchStringParameter.trim();
274        final ListView listView = getListView();
275        listView.setItemsCanFocus(true);
276        listView.setFocusable(true);
277        listView.setClickable(true);
278
279        // I considered something like "searching..." but typically it will
280        // flash on the screen briefly which I found to be more distracting
281        // than beneficial.
282        // This gets updated when the query completes.
283        setTitle("");
284
285        Contact.addListener(mContactListener);
286
287        // When the query completes cons up a new adapter and set our list adapter to that.
288        mQueryHandler = new AsyncQueryHandler(cr) {
289            protected void onQueryComplete(int token, Object cookie, Cursor c) {
290                if (c == null) {
291                    return;
292                }
293                final int threadIdPos = c.getColumnIndex("thread_id");
294                final int addressPos  = c.getColumnIndex("address");
295                final int bodyPos     = c.getColumnIndex("body");
296                final int rowidPos    = c.getColumnIndex("_id");
297
298                int cursorCount = c.getCount();
299                setTitle(getResources().getQuantityString(
300                        R.plurals.search_results_title,
301                        cursorCount,
302                        cursorCount,
303                        searchString));
304
305                // Note that we're telling the CursorAdapter not to do auto-requeries. If we
306                // want to dynamically respond to changes in the search results,
307                // we'll have have to add a setOnDataSetChangedListener().
308                setListAdapter(new CursorAdapter(SearchActivity.this,
309                        c, false /* no auto-requery */) {
310                    @Override
311                    public void bindView(View view, Context context, Cursor cursor) {
312                        final TextView title = (TextView)(view.findViewById(R.id.title));
313                        final TextViewSnippet snippet = (TextViewSnippet)(view.findViewById(R.id.subtitle));
314
315                        String address = cursor.getString(addressPos);
316                        Contact contact = address != null ? Contact.get(address, false) : null;
317
318                        String titleString = contact != null ? contact.getNameAndNumber() : "";
319                        title.setText(titleString);
320
321                        snippet.setText(cursor.getString(bodyPos), searchString);
322
323                        // if the user touches the item then launch the compose message
324                        // activity with some extra parameters to highlight the search
325                        // results and scroll to the latest part of the conversation
326                        // that has a match.
327                        final long threadId = cursor.getLong(threadIdPos);
328                        final long rowid = cursor.getLong(rowidPos);
329
330                        view.setOnClickListener(new View.OnClickListener() {
331                            public void onClick(View v) {
332                                final Intent onClickIntent = new Intent(SearchActivity.this, ComposeMessageActivity.class);
333                                onClickIntent.putExtra("thread_id", threadId);
334                                onClickIntent.putExtra("highlight", searchString);
335                                onClickIntent.putExtra("select_id", rowid);
336                                startActivity(onClickIntent);
337                            }
338                        });
339                    }
340
341                    @Override
342                    public View newView(Context context, Cursor cursor, ViewGroup parent) {
343                        LayoutInflater inflater = LayoutInflater.from(context);
344                        View v = inflater.inflate(R.layout.search_item, parent, false);
345                        return v;
346                    }
347
348                });
349
350                // ListView seems to want to reject the setFocusable until such time
351                // as the list is not empty.  Set it here and request focus.  Without
352                // this the arrow keys (and trackball) fail to move the selection.
353                listView.setFocusable(true);
354                listView.setFocusableInTouchMode(true);
355                listView.requestFocus();
356
357                // Remember the query if there are actual results
358                if (cursorCount > 0) {
359                    SearchRecentSuggestions recent = ((MmsApp)getApplication()).getRecentSuggestions();
360                    if (recent != null) {
361                        recent.saveRecentQuery(
362                                searchString,
363                                getString(R.string.search_history,
364                                        cursorCount, searchString));
365                    }
366                }
367            }
368        };
369
370        // don't pass a projection since the search uri ignores it
371        Uri uri = Telephony.MmsSms.SEARCH_URI.buildUpon()
372                    .appendQueryParameter("pattern", searchString).build();
373
374        // kick off a query for the threads which match the search string
375        mQueryHandler.startQuery(0, null, uri, null, null, null, null);
376
377    }
378}
379