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 17 package com.android.mms.ui; 18 19 import java.util.HashMap; 20 import java.util.regex.Matcher; 21 import java.util.regex.Pattern; 22 23 import android.app.ActionBar; 24 import android.app.ListActivity; 25 import android.app.SearchManager; 26 import android.content.AsyncQueryHandler; 27 import android.content.ContentResolver; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.database.Cursor; 31 import android.graphics.Typeface; 32 import android.net.Uri; 33 import android.os.Bundle; 34 import android.provider.SearchRecentSuggestions; 35 import android.provider.Telephony; 36 import android.text.SpannableString; 37 import android.text.TextPaint; 38 import android.text.style.StyleSpan; 39 import android.util.AttributeSet; 40 import android.view.LayoutInflater; 41 import android.view.MenuItem; 42 import android.view.View; 43 import android.view.ViewGroup; 44 import android.widget.CursorAdapter; 45 import android.widget.ListView; 46 import android.widget.TextView; 47 48 import com.android.mms.MmsApp; 49 import com.android.mms.R; 50 import 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 60 public 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