blob: e7fe0dd6bb53fab83ca5242e4bea998682882205 [file] [log] [blame]
/**
* Copyright (c) 2009, Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.mms.ui;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import android.app.ActionBar;
import android.app.ListActivity;
import android.app.SearchManager;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.provider.SearchRecentSuggestions;
import android.provider.Telephony;
import android.text.SpannableString;
import android.text.TextPaint;
import android.text.style.StyleSpan;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;
import android.widget.ListView;
import android.widget.TextView;
import com.android.mms.MmsApp;
import com.android.mms.R;
import com.android.mms.data.Contact;
/***
* Presents a List of search results. Each item in the list represents a thread which
* matches. The item contains the contact (or phone number) as the "title" and a
* snippet of what matches, below. The snippet is taken from the most recent part of
* the conversation that has a match. Each match within the visible portion of the
* snippet is highlighted.
*/
public class SearchActivity extends ListActivity
{
private AsyncQueryHandler mQueryHandler;
// Track which TextView's show which Contact objects so that we can update
// appropriately when the Contact gets fully loaded.
private HashMap<Contact, TextView> mContactMap = new HashMap<Contact, TextView>();
/*
* Subclass of TextView which displays a snippet of text which matches the full text and
* highlights the matches within the snippet.
*/
public static class TextViewSnippet extends TextView {
private static String sEllipsis = "\u2026";
private static int sTypefaceHighlight = Typeface.BOLD;
private String mFullText;
private String mTargetString;
private Pattern mPattern;
public TextViewSnippet(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TextViewSnippet(Context context) {
super(context);
}
public TextViewSnippet(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
/**
* We have to know our width before we can compute the snippet string. Do that
* here and then defer to super for whatever work is normally done.
*/
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
String fullTextLower = mFullText.toLowerCase();
String targetStringLower = mTargetString.toLowerCase();
int startPos = 0;
int searchStringLength = targetStringLower.length();
int bodyLength = fullTextLower.length();
Matcher m = mPattern.matcher(mFullText);
if (m.find(0)) {
startPos = m.start();
}
TextPaint tp = getPaint();
float searchStringWidth = tp.measureText(mTargetString);
float textFieldWidth = getWidth();
float ellipsisWidth = tp.measureText(sEllipsis);
textFieldWidth -= (2F * ellipsisWidth); // assume we'll need one on both ends
String snippetString = null;
if (searchStringWidth > textFieldWidth) {
snippetString = mFullText.substring(startPos, startPos + searchStringLength);
} else {
int offset = -1;
int start = -1;
int end = -1;
/* TODO: this code could be made more efficient by only measuring the additional
* characters as we widen the string rather than measuring the whole new
* string each time.
*/
while (true) {
offset += 1;
int newstart = Math.max(0, startPos - offset);
int newend = Math.min(bodyLength, startPos + searchStringLength + offset);
if (newstart == start && newend == end) {
// if we couldn't expand out any further then we're done
break;
}
start = newstart;
end = newend;
// pull the candidate string out of the full text rather than body
// because body has been toLower()'ed
String candidate = mFullText.substring(start, end);
if (tp.measureText(candidate) > textFieldWidth) {
// if the newly computed width would exceed our bounds then we're done
// do not use this "candidate"
break;
}
snippetString = String.format(
"%s%s%s",
start == 0 ? "" : sEllipsis,
candidate,
end == bodyLength ? "" : sEllipsis);
}
}
SpannableString spannable = new SpannableString(snippetString);
int start = 0;
m = mPattern.matcher(snippetString);
while (m.find(start)) {
spannable.setSpan(new StyleSpan(sTypefaceHighlight), m.start(), m.end(), 0);
start = m.end();
}
setText(spannable);
// do this after the call to setText() above
super.onLayout(changed, left, top, right, bottom);
}
public void setText(String fullText, String target) {
// Use a regular expression to locate the target string
// within the full text. The target string must be
// found as a word start so we use \b which matches
// word boundaries.
String patternString = "\\b" + Pattern.quote(target);
mPattern = Pattern.compile(patternString, Pattern.CASE_INSENSITIVE);
mFullText = fullText;
mTargetString = target;
requestLayout();
}
}
Contact.UpdateListener mContactListener = new Contact.UpdateListener() {
public void onUpdate(Contact updated) {
TextView tv = mContactMap.get(updated);
if (tv != null) {
tv.setText(updated.getNameAndNumber());
}
}
};
@Override
public void onStop() {
super.onStop();
Contact.removeListener(mContactListener);
}
private long getThreadId(long sourceId, long which) {
Uri.Builder b = Uri.parse("content://mms-sms/messageIdToThread").buildUpon();
b = b.appendQueryParameter("row_id", String.valueOf(sourceId));
b = b.appendQueryParameter("table_to_use", String.valueOf(which));
String s = b.build().toString();
Cursor c = getContentResolver().query(
Uri.parse(s),
null,
null,
null,
null);
if (c != null) {
try {
if (c.moveToFirst()) {
return c.getLong(c.getColumnIndex("thread_id"));
}
} finally {
c.close();
}
}
return -1;
}
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
String searchStringParameter = getIntent().getStringExtra(SearchManager.QUERY);
if (searchStringParameter == null) {
searchStringParameter = getIntent().getStringExtra("intent_extra_data_key" /*SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA*/);
}
final String searchString =
searchStringParameter != null ? searchStringParameter.trim() : searchStringParameter;
// If we're being launched with a source_id then just go to that particular thread.
// Work around the fact that suggestions can only launch the search activity, not some
// arbitrary activity (such as ComposeMessageActivity).
final Uri u = getIntent().getData();
if (u != null && u.getQueryParameter("source_id") != null) {
Thread t = new Thread(new Runnable() {
public void run() {
try {
long sourceId = Long.parseLong(u.getQueryParameter("source_id"));
long whichTable = Long.parseLong(u.getQueryParameter("which_table"));
long threadId = getThreadId(sourceId, whichTable);
final Intent onClickIntent = new Intent(SearchActivity.this, ComposeMessageActivity.class);
onClickIntent.putExtra("highlight", searchString);
onClickIntent.putExtra("select_id", sourceId);
onClickIntent.putExtra("thread_id", threadId);
startActivity(onClickIntent);
finish();
return;
} catch (NumberFormatException ex) {
// ok, we do not have a thread id so continue
}
}
}, "Search thread");
t.start();
return;
}
setContentView(R.layout.search_activity);
ContentResolver cr = getContentResolver();
searchStringParameter = searchStringParameter.trim();
final ListView listView = getListView();
listView.setItemsCanFocus(true);
listView.setFocusable(true);
listView.setClickable(true);
// I considered something like "searching..." but typically it will
// flash on the screen briefly which I found to be more distracting
// than beneficial.
// This gets updated when the query completes.
setTitle("");
Contact.addListener(mContactListener);
// When the query completes cons up a new adapter and set our list adapter to that.
mQueryHandler = new AsyncQueryHandler(cr) {
protected void onQueryComplete(int token, Object cookie, Cursor c) {
if (c == null) {
setTitle(getResources().getQuantityString(
R.plurals.search_results_title,
0,
0,
searchString));
return;
}
final int threadIdPos = c.getColumnIndex("thread_id");
final int addressPos = c.getColumnIndex("address");
final int bodyPos = c.getColumnIndex("body");
final int rowidPos = c.getColumnIndex("_id");
int cursorCount = c.getCount();
setTitle(getResources().getQuantityString(
R.plurals.search_results_title,
cursorCount,
cursorCount,
searchString));
// Note that we're telling the CursorAdapter not to do auto-requeries. If we
// want to dynamically respond to changes in the search results,
// we'll have have to add a setOnDataSetChangedListener().
setListAdapter(new CursorAdapter(SearchActivity.this,
c, false /* no auto-requery */) {
@Override
public void bindView(View view, Context context, Cursor cursor) {
final TextView title = (TextView)(view.findViewById(R.id.title));
final TextViewSnippet snippet = (TextViewSnippet)(view.findViewById(R.id.subtitle));
String address = cursor.getString(addressPos);
Contact contact = address != null ? Contact.get(address, false) : null;
String titleString = contact != null ? contact.getNameAndNumber() : "";
title.setText(titleString);
snippet.setText(cursor.getString(bodyPos), searchString);
// if the user touches the item then launch the compose message
// activity with some extra parameters to highlight the search
// results and scroll to the latest part of the conversation
// that has a match.
final long threadId = cursor.getLong(threadIdPos);
final long rowid = cursor.getLong(rowidPos);
view.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
final Intent onClickIntent = new Intent(SearchActivity.this, ComposeMessageActivity.class);
onClickIntent.putExtra("thread_id", threadId);
onClickIntent.putExtra("highlight", searchString);
onClickIntent.putExtra("select_id", rowid);
startActivity(onClickIntent);
}
});
}
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
LayoutInflater inflater = LayoutInflater.from(context);
View v = inflater.inflate(R.layout.search_item, parent, false);
return v;
}
});
// ListView seems to want to reject the setFocusable until such time
// as the list is not empty. Set it here and request focus. Without
// this the arrow keys (and trackball) fail to move the selection.
listView.setFocusable(true);
listView.setFocusableInTouchMode(true);
listView.requestFocus();
// Remember the query if there are actual results
if (cursorCount > 0) {
SearchRecentSuggestions recent = ((MmsApp)getApplication()).getRecentSuggestions();
if (recent != null) {
recent.saveRecentQuery(
searchString,
getString(R.string.search_history,
cursorCount, searchString));
}
}
}
};
// don't pass a projection since the search uri ignores it
Uri uri = Telephony.MmsSms.SEARCH_URI.buildUpon()
.appendQueryParameter("pattern", searchString).build();
// kick off a query for the threads which match the search string
mQueryHandler.startQuery(0, null, uri, null, null, null, null);
ActionBar actionBar = getActionBar();
actionBar.setDisplayHomeAsUpEnabled(true);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
// The user clicked on the Messaging icon in the action bar. Take them back from
// wherever they came from
finish();
return true;
}
return false;
}
}