1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 package org.mozilla.gecko.search; 6 7 import android.net.Uri; 8 import android.util.Log; 9 import android.util.Xml; 10 11 import org.mozilla.gecko.util.StringUtils; 12 import org.xmlpull.v1.XmlPullParser; 13 import org.xmlpull.v1.XmlPullParserException; 14 15 import java.io.IOException; 16 import java.io.InputStream; 17 import java.util.ArrayList; 18 import java.util.List; 19 import java.util.Locale; 20 import java.util.Set; 21 22 /** 23 * Extend this class to add a new search engine to 24 * the search activity. 25 */ 26 public class SearchEngine { 27 private static final String LOG_TAG = "SearchEngine"; 28 29 private static final String URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; 30 private static final String URLTYPE_SEARCH_HTML = "text/html"; 31 32 private static final String URL_REL_MOBILE = "mobile"; 33 34 // Parameters copied from nsSearchService.js 35 private static final String MOZ_PARAM_LOCALE = "\\{moz:locale\\}"; 36 private static final String MOZ_PARAM_DIST_ID = "\\{moz:distributionID\\}"; 37 private static final String MOZ_PARAM_OFFICIAL = "\\{moz:official\\}"; 38 39 // Supported OpenSearch parameters 40 // See http://opensearch.a9.com/spec/1.1/querysyntax/#core 41 private static final String OS_PARAM_USER_DEFINED = "\\{searchTerms\\??\\}"; 42 private static final String OS_PARAM_INPUT_ENCODING = "\\{inputEncoding\\??\\}"; 43 private static final String OS_PARAM_LANGUAGE = "\\{language\\??\\}"; 44 private static final String OS_PARAM_OUTPUT_ENCODING = "\\{outputEncoding\\??\\}"; 45 private static final String OS_PARAM_OPTIONAL = "\\{(?:\\w+:)?\\w+\\?\\}"; 46 47 // Boilerplate bookmarklet-style JS for injecting CSS into the 48 // head of a web page. The actual CSS is inserted at `%s`. 49 private static final String STYLE_INJECTION_SCRIPT = 50 "javascript:(function(){" + 51 "var tag=document.createElement('style');" + 52 "tag.type='text/css';" + 53 "document.getElementsByTagName('head')[0].appendChild(tag);" + 54 "tag.innerText='%s'})();"; 55 56 // The Gecko search identifier. This will be null for engines that don't ship with the locale. 57 private final String identifier; 58 59 private String shortName; 60 private String iconURL; 61 62 // Ordered list of preferred results URIs. 63 private final List<Uri> resultsUris = new ArrayList<Uri>(); 64 private Uri suggestUri; 65 66 /** 67 * 68 * @param in InputStream of open search plugin XML 69 */ SearchEngine(String identifier, InputStream in)70 public SearchEngine(String identifier, InputStream in) throws IOException, XmlPullParserException { 71 this.identifier = identifier; 72 73 final XmlPullParser parser = Xml.newPullParser(); 74 parser.setInput(in, null); 75 parser.nextTag(); 76 readSearchPlugin(parser); 77 } 78 readSearchPlugin(XmlPullParser parser)79 private void readSearchPlugin(XmlPullParser parser) throws XmlPullParserException, IOException { 80 if (XmlPullParser.START_TAG != parser.getEventType()) { 81 throw new XmlPullParserException("Expected start tag: " + parser.getPositionDescription()); 82 } 83 84 final String name = parser.getName(); 85 if (!"SearchPlugin".equals(name) && !"OpenSearchDescription".equals(name)) { 86 throw new XmlPullParserException("Expected <SearchPlugin> or <OpenSearchDescription> as root tag: " 87 + parser.getPositionDescription()); 88 } 89 90 while (parser.next() != XmlPullParser.END_TAG) { 91 if (parser.getEventType() != XmlPullParser.START_TAG) { 92 continue; 93 } 94 95 final String tag = parser.getName(); 96 if (tag.equals("ShortName")) { 97 readShortName(parser); 98 } else if (tag.equals("Url")) { 99 readUrl(parser); 100 } else if (tag.equals("Image")) { 101 readImage(parser); 102 } else { 103 skip(parser); 104 } 105 } 106 } 107 readShortName(XmlPullParser parser)108 private void readShortName(XmlPullParser parser) throws IOException, XmlPullParserException { 109 parser.require(XmlPullParser.START_TAG, null, "ShortName"); 110 if (parser.next() == XmlPullParser.TEXT) { 111 shortName = parser.getText(); 112 parser.nextTag(); 113 } 114 } 115 readUrl(XmlPullParser parser)116 private void readUrl(XmlPullParser parser) throws XmlPullParserException, IOException { 117 parser.require(XmlPullParser.START_TAG, null, "Url"); 118 119 final String type = parser.getAttributeValue(null, "type"); 120 final String template = parser.getAttributeValue(null, "template"); 121 final String rel = parser.getAttributeValue(null, "rel"); 122 123 Uri uri = Uri.parse(template); 124 125 while (parser.next() != XmlPullParser.END_TAG) { 126 if (parser.getEventType() != XmlPullParser.START_TAG) { 127 continue; 128 } 129 130 final String tag = parser.getName(); 131 132 if (tag.equals("Param")) { 133 final String name = parser.getAttributeValue(null, "name"); 134 final String value = parser.getAttributeValue(null, "value"); 135 uri = uri.buildUpon().appendQueryParameter(name, value).build(); 136 parser.nextTag(); 137 // TODO: Support for other tags 138 //} else if (tag.equals("MozParam")) { 139 } else { 140 skip(parser); 141 } 142 } 143 144 if (type.equals(URLTYPE_SEARCH_HTML)) { 145 // Prefer mobile URIs. 146 if (rel != null && rel.equals(URL_REL_MOBILE)) { 147 resultsUris.add(0, uri); 148 } else { 149 resultsUris.add(uri); 150 } 151 } else if (type.equals(URLTYPE_SUGGEST_JSON)) { 152 suggestUri = uri; 153 } 154 } 155 readImage(XmlPullParser parser)156 private void readImage(XmlPullParser parser) throws XmlPullParserException, IOException { 157 parser.require(XmlPullParser.START_TAG, null, "Image"); 158 159 // TODO: Use width and height to get a preferred icon URL. 160 //final int width = Integer.parseInt(parser.getAttributeValue(null, "width")); 161 //final int height = Integer.parseInt(parser.getAttributeValue(null, "height")); 162 163 if (parser.next() == XmlPullParser.TEXT) { 164 iconURL = parser.getText(); 165 parser.nextTag(); 166 } 167 } 168 skip(XmlPullParser parser)169 private void skip(XmlPullParser parser) throws XmlPullParserException, IOException { 170 if (parser.getEventType() != XmlPullParser.START_TAG) { 171 throw new IllegalStateException(); 172 } 173 int depth = 1; 174 while (depth != 0) { 175 switch (parser.next()) { 176 case XmlPullParser.END_TAG: 177 depth--; 178 break; 179 case XmlPullParser.START_TAG: 180 depth++; 181 break; 182 } 183 } 184 } 185 186 /** 187 * HACKS! We'll need to replace this with endpoints that return the correct content. 188 * 189 * Retrieve a JS snippet, in bookmarklet style, that can be used 190 * to modify the results page. 191 */ getInjectableJs()192 public String getInjectableJs() { 193 final String css; 194 195 if (identifier == null) { 196 css = ""; 197 } else if (identifier.equals("bing")) { 198 css = "#mHeader{display:none}#contentWrapper{margin-top:0}"; 199 } else if (identifier.equals("google")) { 200 css = "#sfcnt,#top_nav{display:none}"; 201 } else if (identifier.equals("yahoo")) { 202 css = "#nav,#header{display:none}"; 203 } else { 204 css = ""; 205 } 206 207 return String.format(STYLE_INJECTION_SCRIPT, css); 208 } 209 getIdentifier()210 public String getIdentifier() { 211 return identifier; 212 } 213 getName()214 public String getName() { 215 return shortName; 216 } 217 getIconURL()218 public String getIconURL() { 219 return iconURL; 220 } 221 222 /** 223 * Finds the search query encoded in a given results URL. 224 * 225 * @param url Current results URL. 226 * @return The search query, or an empty string if a query couldn't be found. 227 */ queryForResultsUrl(String url)228 public String queryForResultsUrl(String url) { 229 final Uri resultsUri = getResultsUri(); 230 final Set<String> names = StringUtils.getQueryParameterNames(resultsUri); 231 for (String name : names) { 232 if (resultsUri.getQueryParameter(name).matches(OS_PARAM_USER_DEFINED)) { 233 return Uri.parse(url).getQueryParameter(name); 234 } 235 } 236 return ""; 237 } 238 239 /** 240 * Create a uri string that can be used to fetch the results page. 241 * 242 * @param query The user's query. This method will escape and encode the query. 243 */ resultsUriForQuery(String query)244 public String resultsUriForQuery(String query) { 245 final Uri resultsUri = getResultsUri(); 246 if (resultsUri == null) { 247 Log.e(LOG_TAG, "No results URL for search engine: " + shortName); 248 return ""; 249 } 250 final String template = Uri.decode(resultsUri.toString()); 251 return paramSubstitution(template, Uri.encode(query)); 252 } 253 254 /** 255 * Create a uri string to fetch autocomplete suggestions. 256 * 257 * @param query The user's query. This method will escape and encode the query. 258 */ getSuggestionTemplate(String query)259 public String getSuggestionTemplate(String query) { 260 if (suggestUri == null) { 261 Log.e(LOG_TAG, "No suggestions template for search engine: " + shortName); 262 return ""; 263 } 264 final String template = Uri.decode(suggestUri.toString()); 265 return paramSubstitution(template, Uri.encode(query)); 266 } 267 268 /** 269 * @return Preferred results URI. 270 */ getResultsUri()271 private Uri getResultsUri() { 272 if (resultsUris.isEmpty()) { 273 return null; 274 } 275 return resultsUris.get(0); 276 } 277 278 /** 279 * Formats template string with proper parameters. Modeled after 280 * ParamSubstitution in nsSearchService.js 281 * 282 * @param template 283 * @param query 284 * @return 285 */ paramSubstitution(String template, String query)286 private String paramSubstitution(String template, String query) { 287 final String locale = Locale.getDefault().toString(); 288 289 template = template.replaceAll(MOZ_PARAM_LOCALE, locale); 290 template = template.replaceAll(MOZ_PARAM_DIST_ID, ""); 291 template = template.replaceAll(MOZ_PARAM_OFFICIAL, "unofficial"); 292 293 template = template.replaceAll(OS_PARAM_USER_DEFINED, query); 294 template = template.replaceAll(OS_PARAM_INPUT_ENCODING, "UTF-8"); 295 296 template = template.replaceAll(OS_PARAM_LANGUAGE, locale); 297 template = template.replaceAll(OS_PARAM_OUTPUT_ENCODING, "UTF-8"); 298 299 // Replace any optional parameters 300 template = template.replaceAll(OS_PARAM_OPTIONAL, ""); 301 302 return template; 303 } 304 } 305