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 com.android.mms.MmsApp; 24 import com.android.mms.R; 25 26 import android.app.ActionBar; 27 import android.app.ListActivity; 28 import android.app.SearchManager; 29 import android.content.AsyncQueryHandler; 30 import android.content.ContentResolver; 31 import android.content.Context; 32 import android.content.Intent; 33 import android.database.Cursor; 34 import android.graphics.Typeface; 35 import android.net.Uri; 36 import android.os.Bundle; 37 import android.provider.SearchRecentSuggestions; 38 39 import android.provider.Telephony; 40 import android.text.SpannableString; 41 import android.text.TextPaint; 42 import android.text.style.StyleSpan; 43 import android.util.AttributeSet; 44 import android.view.LayoutInflater; 45 import android.view.MenuItem; 46 import android.view.View; 47 import android.view.ViewGroup; 48 import android.widget.CursorAdapter; 49 import android.widget.ListView; 50 import android.widget.TextView; 51 52 import com.android.mms.data.Contact; 53 import 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 63 public 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 ActionBar actionBar = getActionBar(); 378 actionBar.setDisplayHomeAsUpEnabled(true); 379 } 380 381 @Override 382 public boolean onOptionsItemSelected(MenuItem item) { 383 switch (item.getItemId()) { 384 case android.R.id.home: 385 // The user clicked on the Messaging icon in the action bar. Take them back from 386 // wherever they came from 387 finish(); 388 return true; 389 } 390 return false; 391 } 392 } 393