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 import android.app.ListActivity; 26 import android.app.SearchManager; 27 import android.content.AsyncQueryHandler; 28 import android.content.ContentResolver; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.database.Cursor; 32 import android.graphics.Color; 33 import android.graphics.Typeface; 34 import android.net.Uri; 35 import android.os.Bundle; 36 import android.provider.SearchRecentSuggestions; 37 38 import android.provider.Telephony; 39 import android.text.SpannableString; 40 import android.text.TextPaint; 41 import android.text.style.ForegroundColorSpan; 42 import android.text.style.StyleSpan; 43 import android.util.AttributeSet; 44 import android.view.LayoutInflater; 45 import android.view.View; 46 import android.view.ViewGroup; 47 import android.widget.CursorAdapter; 48 import android.widget.ListView; 49 import android.widget.TextView; 50 51 import com.android.mms.data.Contact; 52 import com.android.mms.data.Contact.UpdateListener; 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 @Override 208 public void onCreate(Bundle icicle) 209 { 210 super.onCreate(icicle); 211 setContentView(R.layout.search_activity); 212 213 String searchStringParameter = getIntent().getStringExtra(SearchManager.QUERY); 214 if (searchStringParameter == null) { 215 searchStringParameter = getIntent().getStringExtra("intent_extra_data_key" /*SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA*/); 216 } 217 final String searchString = 218 searchStringParameter != null ? searchStringParameter.trim() : searchStringParameter; 219 ContentResolver cr = getContentResolver(); 220 221 searchStringParameter = searchStringParameter.trim(); 222 final ListView listView = getListView(); 223 listView.setItemsCanFocus(true); 224 listView.setFocusable(true); 225 listView.setClickable(true); 226 227 // I considered something like "searching..." but typically it will 228 // flash on the screen briefly which I found to be more distracting 229 // than beneficial. 230 // This gets updated when the query completes. 231 setTitle(""); 232 233 Contact.addListener(mContactListener); 234 235 // When the query completes cons up a new adapter and set our list adapter to that. 236 mQueryHandler = new AsyncQueryHandler(cr) { 237 protected void onQueryComplete(int token, Object cookie, Cursor c) { 238 if (c == null) { 239 return; 240 } 241 final int threadIdPos = c.getColumnIndex("thread_id"); 242 final int addressPos = c.getColumnIndex("address"); 243 final int bodyPos = c.getColumnIndex("body"); 244 final int rowidPos = c.getColumnIndex("_id"); 245 246 int cursorCount = c.getCount(); 247 setTitle(getResources().getQuantityString( 248 R.plurals.search_results_title, 249 cursorCount, 250 cursorCount, 251 searchString)); 252 253 // Note that we're telling the CursorAdapter not to do auto-requeries. If we 254 // want to dynamically respond to changes in the search results, 255 // we'll have have to add a setOnDataSetChangedListener(). 256 setListAdapter(new CursorAdapter(SearchActivity.this, 257 c, false /* no auto-requery */) { 258 @Override 259 public void bindView(View view, Context context, Cursor cursor) { 260 final TextView title = (TextView)(view.findViewById(R.id.title)); 261 final TextViewSnippet snippet = (TextViewSnippet)(view.findViewById(R.id.subtitle)); 262 263 String address = cursor.getString(addressPos); 264 Contact contact = address != null ? Contact.get(address, false) : null; 265 266 String titleString = contact != null ? contact.getNameAndNumber() : ""; 267 title.setText(titleString); 268 269 snippet.setText(cursor.getString(bodyPos), searchString); 270 271 // if the user touches the item then launch the compose message 272 // activity with some extra parameters to highlight the search 273 // results and scroll to the latest part of the conversation 274 // that has a match. 275 final long threadId = cursor.getLong(threadIdPos); 276 final long rowid = cursor.getLong(rowidPos); 277 278 view.setOnClickListener(new View.OnClickListener() { 279 public void onClick(View v) { 280 final Intent onClickIntent = new Intent(SearchActivity.this, ComposeMessageActivity.class); 281 onClickIntent.putExtra("thread_id", threadId); 282 onClickIntent.putExtra("highlight", searchString); 283 onClickIntent.putExtra("select_id", rowid); 284 startActivity(onClickIntent); 285 } 286 }); 287 } 288 289 @Override 290 public View newView(Context context, Cursor cursor, ViewGroup parent) { 291 LayoutInflater inflater = LayoutInflater.from(context); 292 View v = inflater.inflate(R.layout.search_item, parent, false); 293 return v; 294 } 295 296 }); 297 298 // ListView seems to want to reject the setFocusable until such time 299 // as the list is not empty. Set it here and request focus. Without 300 // this the arrow keys (and trackball) fail to move the selection. 301 listView.setFocusable(true); 302 listView.setFocusableInTouchMode(true); 303 listView.requestFocus(); 304 305 // Remember the query if there are actual results 306 if (cursorCount > 0) { 307 SearchRecentSuggestions recent = ((MmsApp)getApplication()).getRecentSuggestions(); 308 if (recent != null) { 309 recent.saveRecentQuery( 310 searchString, 311 getString(R.string.search_history, 312 cursorCount, searchString)); 313 } 314 } 315 } 316 }; 317 318 // don't pass a projection since the search uri ignores it 319 Uri uri = Telephony.MmsSms.SEARCH_URI.buildUpon() 320 .appendQueryParameter("pattern", searchString).build(); 321 322 // kick off a query for the threads which match the search string 323 mQueryHandler.startQuery(0, null, uri, null, null, null, null); 324 325 } 326 } 327