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