1 /* 2 * Copyright (C) 2011 The Android Open Source Project 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 package com.android.browser; 17 18 import com.android.browser.Controller; 19 import com.android.browser.R; 20 import com.android.browser.UI.DropdownChangeListener; 21 import com.android.browser.provider.BrowserProvider; 22 import com.android.browser.search.SearchEngine; 23 24 import android.app.SearchManager; 25 import android.content.Context; 26 import android.database.AbstractCursor; 27 import android.database.Cursor; 28 import android.net.Uri; 29 import android.os.Bundle; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.util.LruCache; 33 import android.webkit.SearchBox; 34 import android.webkit.WebView; 35 36 import java.util.Collections; 37 import java.util.List; 38 39 public class InstantSearchEngine implements SearchEngine, DropdownChangeListener { 40 private static final String TAG = "Browser.InstantSearchEngine"; 41 private static final boolean DBG = false; 42 43 private Controller mController; 44 private SearchBox mSearchBox; 45 private final BrowserSearchboxListener mListener = new BrowserSearchboxListener(); 46 private int mHeight; 47 48 private String mInstantBaseUrl; 49 private final Context mContext; 50 // Used for startSearch( ) calls if for some reason instant 51 // is off, or no searchbox is present. 52 private final SearchEngine mWrapped; 53 54 public InstantSearchEngine(Context context, SearchEngine wrapped) { 55 mContext = context.getApplicationContext(); 56 mWrapped = wrapped; 57 } 58 59 public void setController(Controller controller) { 60 mController = controller; 61 } 62 63 @Override 64 public String getName() { 65 return SearchEngine.GOOGLE; 66 } 67 68 @Override 69 public CharSequence getLabel() { 70 return mContext.getResources().getString(R.string.instant_search_label); 71 } 72 73 @Override 74 public void startSearch(Context context, String query, Bundle appData, String extraData) { 75 if (DBG) Log.d(TAG, "startSearch(" + query + ")"); 76 77 switchSearchboxIfNeeded(); 78 79 // If for some reason we are in a bad state, ensure that the 80 // user gets default search results at the very least. 81 if (mSearchBox == null || !isInstantPage()) { 82 mWrapped.startSearch(context, query, appData, extraData); 83 return; 84 } 85 86 mSearchBox.setQuery(query); 87 mSearchBox.setVerbatim(true); 88 mSearchBox.onsubmit(null); 89 } 90 91 private final class BrowserSearchboxListener extends SearchBox.SearchBoxListener { 92 /* 93 * The maximum number of out of order suggestions we accept 94 * before giving up the wait. 95 */ 96 private static final int MAX_OUT_OF_ORDER = 5; 97 98 /* 99 * We wait for suggestions in increments of 600ms. This is primarily to 100 * guard against suggestions arriving out of order. 101 */ 102 private static final int WAIT_INCREMENT_MS = 600; 103 104 /* 105 * A cache of suggestions received, keyed by the queries they were 106 * received for. 107 */ 108 private final LruCache<String, List<String>> mSuggestions = 109 new LruCache<String, List<String>>(20); 110 111 /* 112 * The last set of suggestions received. We use this reduce UI flicker 113 * in case there is a delay in recieving suggestions. 114 */ 115 private List<String> mLatestSuggestion = Collections.emptyList(); 116 117 @Override 118 public synchronized void onSuggestionsReceived(String query, List<String> suggestions) { 119 if (DBG) Log.d(TAG, "onSuggestionsReceived(" + query + ")"); 120 121 if (!TextUtils.isEmpty(query)) { 122 mSuggestions.put(query, suggestions); 123 mLatestSuggestion = suggestions; 124 } 125 126 notifyAll(); 127 } 128 129 public synchronized List<String> tryWaitForSuggestions(String query) { 130 if (DBG) Log.d(TAG, "tryWait(" + query + ")"); 131 132 int numWaitReturns = 0; 133 134 // This slightly unusual waiting construct is used to safeguard 135 // to some extent against suggestions arriving out of order. We 136 // wait for upto 5 notifyAll( ) calls to check if we received 137 // suggestions for a given query. 138 while (mSuggestions.get(query) == null) { 139 try { 140 wait(WAIT_INCREMENT_MS); 141 ++numWaitReturns; 142 if (numWaitReturns > MAX_OUT_OF_ORDER) { 143 // We've waited too long for suggestions to be returned. 144 // return the last available suggestion. 145 break; 146 } 147 } catch (InterruptedException e) { 148 return Collections.emptyList(); 149 } 150 } 151 152 List<String> suggestions = mSuggestions.get(query); 153 if (suggestions == null) { 154 return mLatestSuggestion; 155 } 156 157 return suggestions; 158 } 159 160 public synchronized void clear() { 161 mSuggestions.evictAll(); 162 } 163 } 164 165 private WebView getCurrentWebview() { 166 if (mController != null) { 167 return mController.getTabControl().getCurrentTopWebView(); 168 } 169 170 return null; 171 } 172 173 /** 174 * Attaches the searchbox to the right browser page, i.e, the currently 175 * visible tab. 176 */ 177 private void switchSearchboxIfNeeded() { 178 final WebView current = getCurrentWebview(); 179 if (current == null) { 180 return; 181 } 182 183 final SearchBox searchBox = current.getSearchBox(); 184 if (searchBox != mSearchBox) { 185 if (mSearchBox != null) { 186 mSearchBox.removeSearchBoxListener(mListener); 187 mListener.clear(); 188 } 189 mSearchBox = searchBox; 190 if (mSearchBox != null) { 191 mSearchBox.addSearchBoxListener(mListener); 192 } 193 } 194 } 195 196 private boolean isInstantPage() { 197 final WebView current = getCurrentWebview(); 198 if (current == null) { 199 return false; 200 } 201 202 final String currentUrl = mController.getCurrentTab().getUrl(); 203 204 if (currentUrl != null) { 205 Uri uri = Uri.parse(currentUrl); 206 final String host = uri.getHost(); 207 final String path = uri.getPath(); 208 209 // Is there a utility class that does this ? 210 if (path != null && host != null) { 211 return host.startsWith("www.google.") && 212 (path.startsWith("/search") || path.startsWith("/webhp")); 213 } 214 return false; 215 } 216 217 return false; 218 } 219 220 private void loadInstantPage() { 221 mController.getActivity().runOnUiThread(new Runnable() { 222 @Override 223 public void run() { 224 final WebView current = getCurrentWebview(); 225 if (current != null) { 226 current.loadUrl(getInstantBaseUrl()); 227 } 228 } 229 }); 230 } 231 232 /** 233 * Queries for a given search term and returns a cursor containing 234 * suggestions ordered by best match. 235 */ 236 @Override 237 public Cursor getSuggestions(Context context, String query) { 238 if (DBG) Log.d(TAG, "getSuggestions(" + query + ")"); 239 if (query == null) { 240 return null; 241 } 242 243 if (!isInstantPage()) { 244 loadInstantPage(); 245 } 246 247 switchSearchboxIfNeeded(); 248 249 mController.registerDropdownChangeListener(this); 250 251 if (mSearchBox == null) { 252 return mWrapped.getSuggestions(context, query); 253 } 254 255 mSearchBox.setDimensions(0, 0, 0, mHeight); 256 mSearchBox.onresize(null); 257 258 if (TextUtils.isEmpty(query)) { 259 // To force the SRP to render an empty (no results) page. 260 mSearchBox.setVerbatim(true); 261 } else { 262 mSearchBox.setVerbatim(false); 263 } 264 mSearchBox.setQuery(query); 265 mSearchBox.onchange(null); 266 267 // Don't bother waiting for suggestions for an empty query. We still 268 // set the query so that the SRP clears itself. 269 if (TextUtils.isEmpty(query)) { 270 return new SuggestionsCursor(Collections.<String>emptyList()); 271 } else { 272 return new SuggestionsCursor(mListener.tryWaitForSuggestions(query)); 273 } 274 } 275 276 @Override 277 public boolean supportsSuggestions() { 278 return true; 279 } 280 281 @Override 282 public void close() { 283 if (mController != null) { 284 mController.registerDropdownChangeListener(null); 285 } 286 if (mSearchBox != null) { 287 mSearchBox.removeSearchBoxListener(mListener); 288 } 289 mListener.clear(); 290 mWrapped.close(); 291 } 292 293 @Override 294 public boolean supportsVoiceSearch() { 295 return false; 296 } 297 298 @Override 299 public String toString() { 300 return "InstantSearchEngine {" + hashCode() + "}"; 301 } 302 303 @Override 304 public boolean wantsEmptyQuery() { 305 return true; 306 } 307 308 private int rescaleHeight(int height) { 309 final WebView current = getCurrentWebview(); 310 if (current == null) { 311 return 0; 312 } 313 314 final float scale = current.getScale(); 315 if (scale != 0) { 316 return (int) (height / scale); 317 } 318 319 return height; 320 } 321 322 @Override 323 public void onNewDropdownDimensions(int height) { 324 final int rescaledHeight = rescaleHeight(height); 325 326 if (rescaledHeight != mHeight) { 327 mHeight = rescaledHeight; 328 if (mSearchBox != null) { 329 mSearchBox.setDimensions(0, 0, 0, rescaledHeight); 330 mSearchBox.onresize(null); 331 } 332 } 333 } 334 335 private String getInstantBaseUrl() { 336 if (mInstantBaseUrl == null) { 337 String url = mContext.getResources().getString(R.string.instant_base); 338 if (url.indexOf("{CID}") != -1) { 339 url = url.replace("{CID}", 340 BrowserProvider.getClientId(mContext.getContentResolver())); 341 } 342 mInstantBaseUrl = url; 343 } 344 345 return mInstantBaseUrl; 346 } 347 348 // Indices of the columns in the below arrays. 349 private static final int COLUMN_INDEX_ID = 0; 350 private static final int COLUMN_INDEX_QUERY = 1; 351 private static final int COLUMN_INDEX_ICON = 2; 352 private static final int COLUMN_INDEX_TEXT_1 = 3; 353 354 private static final String[] COLUMNS_WITHOUT_DESCRIPTION = new String[] { 355 "_id", 356 SearchManager.SUGGEST_COLUMN_QUERY, 357 SearchManager.SUGGEST_COLUMN_ICON_1, 358 SearchManager.SUGGEST_COLUMN_TEXT_1, 359 }; 360 361 private static class SuggestionsCursor extends AbstractCursor { 362 private final List<String> mSuggestions; 363 364 public SuggestionsCursor(List<String> suggestions) { 365 mSuggestions = suggestions; 366 } 367 368 @Override 369 public int getCount() { 370 return mSuggestions.size(); 371 } 372 373 @Override 374 public String[] getColumnNames() { 375 return COLUMNS_WITHOUT_DESCRIPTION; 376 } 377 378 private String format(String suggestion) { 379 if (TextUtils.isEmpty(suggestion)) { 380 return ""; 381 } 382 return suggestion; 383 } 384 385 @Override 386 public String getString(int column) { 387 if (mPos >= 0 && mPos < mSuggestions.size()) { 388 if ((column == COLUMN_INDEX_QUERY) || (column == COLUMN_INDEX_TEXT_1)) { 389 return format(mSuggestions.get(mPos)); 390 } else if (column == COLUMN_INDEX_ICON) { 391 return String.valueOf(R.drawable.magnifying_glass); 392 } 393 } 394 return null; 395 } 396 397 @Override 398 public double getDouble(int column) { 399 throw new UnsupportedOperationException(); 400 } 401 402 @Override 403 public float getFloat(int column) { 404 throw new UnsupportedOperationException(); 405 } 406 407 @Override 408 public int getInt(int column) { 409 if (column == COLUMN_INDEX_ID) { 410 return mPos; 411 } 412 throw new UnsupportedOperationException(); 413 } 414 415 @Override 416 public long getLong(int column) { 417 throw new UnsupportedOperationException(); 418 } 419 420 @Override 421 public short getShort(int column) { 422 throw new UnsupportedOperationException(); 423 } 424 425 @Override 426 public boolean isNull(int column) { 427 throw new UnsupportedOperationException(); 428 } 429 } 430 } 431