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