Added basic support for searching events

 - Reusing agenda view for displaying search results
 - Currently not fragment-ized

Change-Id: I687b61ca86f92a54c1e402b881edd83111806161
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 116525b..f6bef41 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -75,6 +75,10 @@
             </intent-filter>
         </activity>
 
+        <!-- Make all activities a searchable context -->
+        <meta-data android:name="android.app.default_searchable"
+            android:value=".SearchActivity"/>
+
         <activity android:name="MonthActivity" android:label="@string/month_view"
             android:theme="@style/CalendarTheme" />
         <activity android:name="WeekActivity" android:label="@string/week_view"
@@ -124,6 +128,15 @@
 
         <activity android:name="SelectCalendarsActivity" android:label="@string/calendars_title" />
         <activity android:name="CalendarPreferenceActivity" android:label="@string/preferences_title" />
+
+        <activity android:name="SearchActivity" android:label="@string/search_title"
+            android:launchMode="singleTop" android:theme="@android:style/Theme.Light">
+            <intent-filter>
+                <action android:name="android.intent.action.SEARCH"/>
+            </intent-filter>
+            <meta-data android:name="android.app.searchable" android:resource="@xml/searchable"/>
+        </activity>
+
         <activity android:name="AlertActivity" android:launchMode="singleInstance"
              android:theme="@android:style/Theme.Light" android:excludeFromRecents="true" />
         <receiver android:name="AlertReceiver">
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 8aa449f..52fe9e3 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -120,6 +120,9 @@
     <!-- This is a label on a menu item. Pressing this menu item allows the
          user to view and edit his Settings (or Preferences) -->
     <string name="menu_preferences">"Settings"</string>
+    <!-- This is a label on a menu item. Pressing this menu item allows the
+         user to search their events. -->
+    <string name="search">"Search"</string>
 
     <!-- Month view -->
     <skip />
@@ -247,6 +250,11 @@
     <!-- This is shown at the bottom of the agenda view showing the range of events shown. -->
     <string name="show_newer_events">Showing events until <xliff:g id="newest_search_range">%1$s</xliff:g>. Tap to look for more.</string>
 
+    <!-- Search activity strings -->
+    <skip />
+    <!-- Title of the search screen -->
+    <string name="search_title">Search my calendars</string>
+
     <!-- ICS Import activity -->
     <skip />
     <!-- This is a abbreviation for 'Number of events' and is a label next to
diff --git a/res/xml/searchable.xml b/res/xml/searchable.xml
new file mode 100644
index 0000000..ae77ffd
--- /dev/null
+++ b/res/xml/searchable.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<searchable xmlns:android="http://schemas.android.com/apk/res/android"
+    android:label="@string/app_label" >
+</searchable>
\ No newline at end of file
diff --git a/src/com/android/calendar/AgendaListView.java b/src/com/android/calendar/AgendaListView.java
index b9e9ea7..d818419 100644
--- a/src/com/android/calendar/AgendaListView.java
+++ b/src/com/android/calendar/AgendaListView.java
@@ -20,6 +20,7 @@
 import com.android.calendar.AgendaWindowAdapter.EventInfo;
 import com.android.calendar.CalendarController.EventType;
 
+import android.content.Context;
 import android.graphics.Rect;
 import android.text.format.Time;
 import android.util.Log;
@@ -35,19 +36,18 @@
     private static final boolean DEBUG = false;
 
     private AgendaWindowAdapter mWindowAdapter;
-
     private DeleteEventHelper mDeleteEventHelper;
 
-    public AgendaListView(AgendaActivity agendaActivity) {
-        super(agendaActivity, null);
 
+    public AgendaListView(Context context) {
+        super(context, null);
         setOnItemClickListener(this);
         setChoiceMode(ListView.CHOICE_MODE_SINGLE);
         setVerticalScrollBarEnabled(false);
-        mWindowAdapter = new AgendaWindowAdapter(agendaActivity, this);
+        mWindowAdapter = new AgendaWindowAdapter(context, this);
         setAdapter(mWindowAdapter);
         mDeleteEventHelper =
-            new DeleteEventHelper(agendaActivity, null, false /* don't exit when done */);
+            new DeleteEventHelper(context, null, false /* don't exit when done */);
     }
 
     @Override protected void onDetachedFromWindow() {
@@ -71,6 +71,16 @@
         mWindowAdapter.refresh(time, forced);
     }
 
+    public void search(String searchQuery, boolean forced) {
+        Time time = new Time();
+        long goToTime = getFirstVisibleTime();
+        if (goToTime <= 0) {
+            goToTime = System.currentTimeMillis();
+        }
+        time.set(goToTime);
+        mWindowAdapter.refresh(time, searchQuery, forced);
+    }
+
     public void refresh(boolean forced) {
         Time time = new Time();
         long goToTime = getFirstVisibleTime();
diff --git a/src/com/android/calendar/AgendaWindowAdapter.java b/src/com/android/calendar/AgendaWindowAdapter.java
index 5d43c8f..e3720d4 100644
--- a/src/com/android/calendar/AgendaWindowAdapter.java
+++ b/src/com/android/calendar/AgendaWindowAdapter.java
@@ -18,9 +18,11 @@
 
 import android.content.AsyncQueryHandler;
 import android.content.ContentResolver;
+import android.content.ContentUris;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
+import android.provider.Calendar;
 import android.provider.Calendar.Attendees;
 import android.provider.Calendar.Calendars;
 import android.provider.Calendar.Instances;
@@ -64,7 +66,11 @@
     static final boolean DEBUGLOG = false;
     private static String TAG = "AgendaWindowAdapter";
 
-    private static final String AGENDA_SORT_ORDER = "startDay ASC, begin ASC, title ASC";
+    private static final String AGENDA_SORT_ORDER =
+        Calendar.Instances.START_DAY + " ASC, " +
+        Calendar.Instances.BEGIN + " ASC, " +
+        Calendar.Events.TITLE + " ASC";
+
     public static final int INDEX_TITLE = 1;
     public static final int INDEX_EVENT_LOCATION = 2;
     public static final int INDEX_ALL_DAY = 3;
@@ -108,8 +114,8 @@
 
     private static final int PREFETCH_BOUNDARY = 1;
 
-    // Times to auto-expand/retry query after getting no data
-    private static final int RETRIES_ON_NO_DATA = 0;
+    /** Times to auto-expand/retry query after getting no data */
+    private static final int RETRIES_ON_NO_DATA = 1;
 
     private Context mContext;
 
@@ -117,11 +123,14 @@
 
     private AgendaListView mAgendaListView;
 
-    private int mRowCount; // The sum of the rows in all the adapters
+    /** The sum of the rows in all the adapters */
+    private int mRowCount;
 
+    /** The number of times we have queried and gotten no results back */
     private int mEmptyCursorCount;
 
-    private DayAdapterInfo mLastUsedInfo; // Cached value of the last used adapter.
+    /** Cached value of the last used adapter */
+    private DayAdapterInfo mLastUsedInfo;
 
     private LinkedList<DayAdapterInfo> mAdapterInfos = new LinkedList<DayAdapterInfo>();
 
@@ -133,24 +142,24 @@
 
     private boolean mDoneSettingUpHeaderFooter = false;
 
-    /*
+    /**
      * When the user scrolled to the top, a query will be made for older events
      * and this will be incremented. Don't make more requests if
      * mOlderRequests > mOlderRequestsProcessed.
      */
     private int mOlderRequests;
 
-    // Number of "older" query that has been processed.
+    /** Number of "older" query that has been processed. */
     private int mOlderRequestsProcessed;
 
-    /*
+    /**
      * When the user scrolled to the bottom, a query will be made for newer
      * events and this will be incremented. Don't make more requests if
      * mNewerRequests > mNewerRequestsProcessed.
      */
     private int mNewerRequests;
 
-    // Number of "newer" query that has been processed.
+    /** Number of "newer" query that has been processed. */
     private int mNewerRequestsProcessed;
 
     // Note: Formatter is not thread safe. Fine for now as it is only used by the main thread.
@@ -160,6 +169,9 @@
     private boolean mShuttingDown;
     private boolean mHideDeclined;
 
+    /** The current search query, or null if none */
+    private String mSearchQuery;
+
     // Types of Query
     private static final int QUERY_TYPE_OLDER = 0; // Query for older events
     private static final int QUERY_TYPE_NEWER = 1; // Query for newer events
@@ -174,6 +186,8 @@
 
         int end;
 
+        String searchQuery;
+
         int queryType;
 
         public QuerySpec(int queryType) {
@@ -188,6 +202,7 @@
             result = prime * result + (int) (queryStartMillis ^ (queryStartMillis >>> 32));
             result = prime * result + queryType;
             result = prime * result + start;
+            result = prime * result + searchQuery.hashCode();
             if (goToTime != null) {
                 long goToTimeMillis = goToTime.toMillis(false);
                 result = prime * result + (int) (goToTimeMillis ^ (goToTimeMillis >>> 32));
@@ -202,9 +217,11 @@
             if (getClass() != obj.getClass()) return false;
             QuerySpec other = (QuerySpec) obj;
             if (end != other.end || queryStartMillis != other.queryStartMillis
-                    || queryType != other.queryType || start != other.start) {
+                    || queryType != other.queryType || start != other.start
+                    || Utils.equals(searchQuery, other.searchQuery)) {
                 return false;
             }
+
             if (goToTime != null) {
                 if (goToTime.toMillis(false) != other.goToTime.toMillis(false)) {
                     return false;
@@ -259,16 +276,18 @@
         }
     }
 
-    public AgendaWindowAdapter(AgendaActivity agendaActivity,
+    public AgendaWindowAdapter(Context context,
             AgendaListView agendaListView) {
-        mContext = agendaActivity;
+        mContext = context;
         mAgendaListView = agendaListView;
-        mQueryHandler = new QueryHandler(agendaActivity.getContentResolver());
+        mQueryHandler = new QueryHandler(context.getContentResolver());
 
         mStringBuilder = new StringBuilder(50);
         mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
 
-        LayoutInflater inflater = (LayoutInflater) agendaActivity
+        mSearchQuery = null;
+
+        LayoutInflater inflater = (LayoutInflater) context
                 .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
         mHeaderView = (TextView)inflater.inflate(R.layout.agenda_header_footer, null);
         mFooterView = (TextView)inflater.inflate(R.layout.agenda_header_footer, null);
@@ -469,6 +488,15 @@
     }
 
     public void refresh(Time goToTime, boolean forced) {
+        refresh(goToTime, mSearchQuery, forced);
+    }
+
+    public void refresh(Time goToTime, String searchQuery, boolean forced) {
+        if (!Utils.equals(searchQuery, mSearchQuery)) {
+            // When we change search terms, clean up any old state, start over
+            resetInstanceFields();
+        }
+        mSearchQuery = searchQuery;
         if (DEBUGLOG) {
             Log.e(TAG, "refresh " + goToTime.toString() + (forced ? " forced" : " not forced"));
         }
@@ -484,7 +512,7 @@
         // Query for a total of MIN_QUERY_DURATION days
         int endDay = startDay + MIN_QUERY_DURATION;
 
-        queueQuery(startDay, endDay, goToTime, QUERY_TYPE_CLEAN);
+        queueQuery(startDay, endDay, goToTime, searchQuery, QUERY_TYPE_CLEAN);
     }
 
     public void close() {
@@ -539,6 +567,20 @@
         }
     }
 
+    /**
+     * Resets any transient state in this instance and puts it back into a state
+     * where it can be treated as a newly instantiated adapter
+     *
+     * TODO are these all of the fields that need to be reset?
+     */
+    private void resetInstanceFields() {
+        mEmptyCursorCount = 0;
+        mNewerRequests = 0;
+        mNewerRequestsProcessed = 0;
+        mOlderRequests = 0;
+        mOlderRequestsProcessed = 0;
+    }
+
     private String buildQuerySelection() {
         // Respect the preference to show/hide declined events
 
@@ -551,13 +593,17 @@
         }
     }
 
-    private Uri buildQueryUri(int start, int end) {
-        StringBuilder path = new StringBuilder();
-        path.append(start);
-        path.append('/');
-        path.append(end);
-        Uri uri = Uri.withAppendedPath(Instances.CONTENT_BY_DAY_URI, path.toString());
-        return uri;
+    private Uri buildQueryUri(int start, int end, String searchQuery) {
+        Uri rootUri = searchQuery == null ?
+                Instances.CONTENT_BY_DAY_URI :
+                Instances.CONTENT_SEARCH_BY_DAY_URI;
+        Uri.Builder builder = rootUri.buildUpon();
+        ContentUris.appendId(builder, start);
+        ContentUris.appendId(builder, end);
+        if (searchQuery != null) {
+            builder.appendPath(searchQuery);
+        }
+        return builder.build();
     }
 
     private boolean isInRange(int start, int end) {
@@ -584,15 +630,18 @@
         return queryDuration;
     }
 
-    private boolean queueQuery(int start, int end, Time goToTime, int queryType) {
+    private boolean queueQuery(int start, int end, Time goToTime,
+            String searchQuery, int queryType) {
         QuerySpec queryData = new QuerySpec(queryType);
         queryData.goToTime = goToTime;
         queryData.start = start;
         queryData.end = end;
+        queryData.searchQuery = searchQuery;
         return queueQuery(queryData);
     }
 
     private boolean queueQuery(QuerySpec queryData) {
+        queryData.searchQuery = mSearchQuery;
         Boolean queuedQuery;
         synchronized (mQueryQueue) {
             queuedQuery = false;
@@ -634,9 +683,12 @@
 
         mQueryHandler.cancelOperation(0);
         if (BASICLOG) queryData.queryStartMillis = System.nanoTime();
-        mQueryHandler.startQuery(0, queryData, buildQueryUri(
-                queryData.start, queryData.end), PROJECTION,
-                buildQuerySelection(), null, AGENDA_SORT_ORDER);
+
+        Uri queryUri = buildQueryUri(
+                queryData.start, queryData.end, queryData.searchQuery);
+        mQueryHandler.startQuery(0, queryData, queryUri,
+                PROJECTION, buildQuerySelection(), null,
+                AGENDA_SORT_ORDER);
     }
 
     private String formatDateString(int julianDay) {
diff --git a/src/com/android/calendar/SearchActivity.java b/src/com/android/calendar/SearchActivity.java
new file mode 100644
index 0000000..d5b5631
--- /dev/null
+++ b/src/com/android/calendar/SearchActivity.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * 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.calendar;
+
+import static android.provider.Calendar.EVENT_BEGIN_TIME;
+import dalvik.system.VMRuntime;
+import android.app.Activity;
+import android.app.SearchManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.database.ContentObserver;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.Calendar.Events;
+import android.text.format.Time;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+
+/**
+ */
+public class SearchActivity extends Activity implements Navigator {
+
+    private static final String TAG = SearchActivity.class.getSimpleName();
+
+    private static boolean DEBUG = false;
+
+    private static final long INITIAL_HEAP_SIZE = 4*1024*1024;
+
+    protected static final String BUNDLE_KEY_RESTORE_TIME = "key_restore_time";
+
+    protected static final String BUNDLE_KEY_RESTORE_SEARCH_QUERY =
+        "key_restore_search_query";
+
+    private ContentResolver mContentResolver;
+
+    private AgendaListView mAgendaListView;
+
+    private Time mTime;
+
+    private BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            String action = intent.getAction();
+            if (action.equals(Intent.ACTION_TIME_CHANGED)
+                    || action.equals(Intent.ACTION_DATE_CHANGED)
+                    || action.equals(Intent.ACTION_TIMEZONE_CHANGED)) {
+                mAgendaListView.refresh(true);
+            }
+        }
+    };
+
+    private ContentObserver mObserver = new ContentObserver(new Handler()) {
+        @Override
+        public boolean deliverSelfNotifications() {
+            return true;
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            mAgendaListView.refresh(true);
+        }
+    };
+
+    @Override
+    protected void onCreate(Bundle icicle) {
+        super.onCreate(icicle);
+
+        setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL);
+
+        // Eliminate extra GCs during startup by setting the initial heap size to 4MB.
+        // TODO: We should restore the old heap size once the activity reaches the idle state
+        VMRuntime.getRuntime().setMinimumHeapSize(INITIAL_HEAP_SIZE);
+
+        mAgendaListView = new AgendaListView(this);
+        setContentView(mAgendaListView);
+
+        mContentResolver = getContentResolver();
+
+        setTitle(R.string.search);
+
+        long millis = 0;
+        mTime = new Time();
+        if (icicle != null) {
+            // Returns 0 if key not found
+            millis = icicle.getLong(BUNDLE_KEY_RESTORE_TIME);
+            if (DEBUG) {
+                Log.v(TAG, "Restore value from icicle: " + millis);
+            }
+        }
+        if (millis == 0) {
+            // Didn't find a time in the bundle, look in intent or current time
+            millis = Utils.timeFromIntentInMillis(getIntent());
+        }
+        Intent intent = getIntent();
+        mTime.set(millis);
+        if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
+            String query = intent.getStringExtra(SearchManager.QUERY);
+            search(query);
+        }
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        // From the Android Dev Guide: "It's important to note that when
+        // onNewIntent(Intent) is called, the Activity has not been restarted,
+        // so the getIntent() method will still return the Intent that was first
+        // received with onCreate(). This is why setIntent(Intent) is called
+        // inside onNewIntent(Intent) (just in case you call getIntent() at a
+        // later time)."
+        setIntent(intent);
+        handleIntent(intent);
+    }
+
+    private void handleIntent(Intent intent) {
+        if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
+            String query = intent.getStringExtra(SearchManager.QUERY);
+            search(query);
+        } else {
+            long time = Utils.timeFromIntentInMillis(intent);
+            if (time > 0) {
+                mTime.set(time);
+                goTo(mTime, false);
+            }
+        }
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+
+        long firstVisibleTime = mAgendaListView.getFirstVisibleTime();
+        if (firstVisibleTime > 0) {
+            mTime.set(firstVisibleTime);
+            outState.putLong(BUNDLE_KEY_RESTORE_TIME, firstVisibleTime);
+            if (DEBUG) {
+                Log.v(TAG, "onSaveInstanceState " + mTime.toString());
+            }
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        if (DEBUG) {
+            Log.v(TAG, "OnResume to " + mTime.toString());
+        }
+
+        SharedPreferences prefs = CalendarPreferenceActivity.getSharedPreferences(
+                getApplicationContext());
+        boolean hideDeclined = prefs.getBoolean(
+                CalendarPreferenceActivity.KEY_HIDE_DECLINED, false);
+
+        mAgendaListView.setHideDeclinedEvents(hideDeclined);
+        mAgendaListView.goTo(mTime, true);
+        mAgendaListView.onResume();
+
+        // Register for Intent broadcasts
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(Intent.ACTION_TIME_CHANGED);
+        filter.addAction(Intent.ACTION_DATE_CHANGED);
+        filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
+        registerReceiver(mIntentReceiver, filter);
+
+        mContentResolver.registerContentObserver(Events.CONTENT_URI, true, mObserver);
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+
+        mAgendaListView.onPause();
+        mContentResolver.unregisterContentObserver(mObserver);
+        unregisterReceiver(mIntentReceiver);
+
+        // Record Agenda View as the (new) default detailed view.
+        Utils.setDefaultView(this, CalendarApplication.AGENDA_VIEW_ID);
+    }
+
+    @Override
+    public boolean onPrepareOptionsMenu(Menu menu) {
+        MenuHelper.onPrepareOptionsMenu(this, menu);
+        return super.onPrepareOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        MenuHelper.onCreateOptionsMenu(menu);
+        return super.onCreateOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        MenuHelper.onOptionsItemSelected(this, item, this);
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    public boolean onKeyDown(int keyCode, KeyEvent event) {
+        switch (keyCode) {
+            case KeyEvent.KEYCODE_DEL:
+                // Delete the currently selected event (if any)
+                mAgendaListView.deleteSelectedEvent();
+                break;
+        }
+        return super.onKeyDown(keyCode, event);
+    }
+
+    private void search(String searchQuery) {
+        mAgendaListView.search(searchQuery, true);
+    }
+
+
+    /* Navigator interface methods */
+    public void goToToday() {
+        Time now = new Time();
+        now.setToNow();
+        mAgendaListView.goTo(now, true); // Force refresh
+    }
+
+    public void goTo(Time time, boolean animate) {
+        mAgendaListView.goTo(time, false);
+    }
+
+    public long getSelectedTime() {
+        return mAgendaListView.getSelectedTime();
+    }
+
+    public boolean getAllDay() {
+        return false;
+    }
+
+}
diff --git a/src/com/android/calendar/Utils.java b/src/com/android/calendar/Utils.java
index e928c70..b3f9e1d 100644
--- a/src/com/android/calendar/Utils.java
+++ b/src/com/android/calendar/Utils.java
@@ -307,4 +307,14 @@
             }
         }
     }
+
+    /**
+     * Null-safe object comparison
+     * @param s1
+     * @param s2
+     * @return
+     */
+    public static boolean equals(Object o1, Object o2) {
+        return o1 == null ? o2 == null : o1.equals(o2);
+    }
 }
diff --git a/tests/src/com/android/calendar/UtilsTests.java b/tests/src/com/android/calendar/UtilsTests.java
index dba024b..41423cb 100644
--- a/tests/src/com/android/calendar/UtilsTests.java
+++ b/tests/src/com/android/calendar/UtilsTests.java
@@ -76,4 +76,17 @@
         Utils.checkForDuplicateNames(mIsDuplicateName, mDuplicateNameCursor, NAME_COLUMN);
         assertEquals(mIsDuplicateName, mIsDuplicateNameExpected);
     }
+
+    @Smoke
+    @SmallTest
+    public void testEquals() {
+        assertTrue(Utils.equals(null, null));
+        assertFalse(Utils.equals("", null));
+        assertFalse(Utils.equals(null, ""));
+        assertTrue(Utils.equals("",""));
+
+        Integer int1 = new Integer(1);
+        Integer int2 = new Integer(1);
+        assertTrue(Utils.equals(int1, int2));
+    }
 }