Issue 521, avoid an NPE
[zxing.git] / android / src / com / google / zxing / client / android / book / SearchBookContentsActivity.java
1 /*
2  * Copyright (C) 2008 ZXing authors
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
17 package com.google.zxing.client.android.book;
18
19 import android.app.Activity;
20 import android.content.Intent;
21 import android.content.res.Configuration;
22 import android.os.Bundle;
23 import android.os.Handler;
24 import android.os.Message;
25 import android.util.Log;
26 import android.view.KeyEvent;
27 import android.view.LayoutInflater;
28 import android.view.View;
29 import android.webkit.CookieManager;
30 import android.webkit.CookieSyncManager;
31 import android.widget.Button;
32 import android.widget.EditText;
33 import android.widget.ListView;
34 import android.widget.TextView;
35 import org.apache.http.Header;
36 import org.apache.http.HttpEntity;
37 import org.apache.http.HttpResponse;
38 import org.apache.http.client.methods.HttpGet;
39 import org.apache.http.client.methods.HttpHead;
40 import org.apache.http.client.methods.HttpUriRequest;
41 import org.json.JSONArray;
42 import org.json.JSONException;
43 import org.json.JSONObject;
44
45 import java.io.ByteArrayOutputStream;
46 import java.io.IOException;
47 import java.net.URI;
48 import java.util.ArrayList;
49 import java.util.List;
50 import java.util.regex.Pattern;
51
52 import com.google.zxing.client.android.R;
53 import com.google.zxing.client.android.Intents;
54 import com.google.zxing.client.android.AndroidHttpClient;
55
56 /**
57  * Uses Google Book Search to find a word or phrase in the requested book.
58  *
59  * @author dswitkin@google.com (Daniel Switkin)
60  */
61 public final class SearchBookContentsActivity extends Activity {
62
63   private static final String TAG = SearchBookContentsActivity.class.getSimpleName();
64
65   private static final String USER_AGENT = "ZXing (Android)";
66   private static final Pattern TAG_PATTERN = Pattern.compile("\\<.*?\\>");
67   private static final Pattern LT_ENTITY_PATTERN = Pattern.compile("&lt;");
68   private static final Pattern GT_ENTITY_PATTERN = Pattern.compile("&gt;");
69   private static final Pattern QUOTE_ENTITY_PATTERN = Pattern.compile("&#39;");
70   private static final Pattern QUOT_ENTITY_PATTERN = Pattern.compile("&quot;");
71
72   private NetworkThread networkThread;
73   private String isbn;
74   private EditText queryTextView;
75   private Button queryButton;
76   private ListView resultListView;
77   private TextView headerView;
78
79   private final Handler handler = new Handler() {
80     @Override
81     public void handleMessage(Message message) {
82       switch (message.what) {
83         case R.id.search_book_contents_succeeded:
84           handleSearchResults((JSONObject) message.obj);
85           resetForNewQuery();
86           break;
87         case R.id.search_book_contents_failed:
88           resetForNewQuery();
89           headerView.setText(R.string.msg_sbc_failed);
90           break;
91       }
92     }
93   };
94
95   private final Button.OnClickListener buttonListener = new Button.OnClickListener() {
96     public void onClick(View view) {
97       launchSearch();
98     }
99   };
100
101   private final View.OnKeyListener keyListener = new View.OnKeyListener() {
102     public boolean onKey(View view, int keyCode, KeyEvent event) {
103       if (keyCode == KeyEvent.KEYCODE_ENTER) {
104         launchSearch();
105         return true;
106       }
107       return false;
108     }
109   };
110
111   String getISBN() {
112     return isbn;
113   }
114
115   @Override
116   public void onCreate(Bundle icicle) {
117     super.onCreate(icicle);
118
119     // Make sure that expired cookies are removed on launch.
120     CookieSyncManager.createInstance(this);
121     CookieManager.getInstance().removeExpiredCookie();
122
123     Intent intent = getIntent();
124     if (intent == null || (!intent.getAction().equals(Intents.SearchBookContents.ACTION))) {
125       finish();
126       return;
127     }
128
129     isbn = intent.getStringExtra(Intents.SearchBookContents.ISBN);
130     if (isbn.startsWith("http://google.com/books?id=")) {
131       setTitle(getString(R.string.sbc_name));
132     } else {
133       setTitle(getString(R.string.sbc_name) + ": ISBN " + isbn);
134     }
135
136     setContentView(R.layout.search_book_contents);
137     queryTextView = (EditText) findViewById(R.id.query_text_view);
138
139     String initialQuery = intent.getStringExtra(Intents.SearchBookContents.QUERY);
140     if (initialQuery != null && initialQuery.length() > 0) {
141       // Populate the search box but don't trigger the search
142       queryTextView.setText(initialQuery);
143     }
144     queryTextView.setOnKeyListener(keyListener);
145
146     queryButton = (Button) findViewById(R.id.query_button);
147     queryButton.setOnClickListener(buttonListener);
148
149     resultListView = (ListView) findViewById(R.id.result_list_view);
150     LayoutInflater factory = LayoutInflater.from(this);
151     headerView = (TextView) factory.inflate(R.layout.search_book_contents_header,
152         resultListView, false);
153     resultListView.addHeaderView(headerView);
154   }
155
156   @Override
157   protected void onResume() {
158     super.onResume();
159     queryTextView.selectAll();
160   }
161
162   @Override
163   public void onConfigurationChanged(Configuration config) {
164     // Do nothing, this is to prevent the activity from being restarted when the keyboard opens.
165     super.onConfigurationChanged(config);
166   }
167
168   private void resetForNewQuery() {
169     networkThread = null;
170     queryTextView.setEnabled(true);
171     queryTextView.selectAll();
172     queryButton.setEnabled(true);
173   }
174
175   private void launchSearch() {
176     if (networkThread == null) {
177       String query = queryTextView.getText().toString();
178       if (query != null && query.length() > 0) {
179         networkThread = new NetworkThread(isbn, query, handler);
180         networkThread.start();
181         headerView.setText(R.string.msg_sbc_searching_book);
182         resultListView.setAdapter(null);
183         queryTextView.setEnabled(false);
184         queryButton.setEnabled(false);
185       }
186     }
187   }
188
189   // Currently there is no way to distinguish between a query which had no results and a book
190   // which is not searchable - both return zero results.
191   private void handleSearchResults(JSONObject json) {
192     try {
193       int count = json.getInt("number_of_results");
194       headerView.setText("Found " + (count == 1 ? "1 result" : count + " results"));
195       if (count > 0) {
196         JSONArray results = json.getJSONArray("search_results");
197         SearchBookContentsResult.setQuery(queryTextView.getText().toString());
198         List<SearchBookContentsResult> items = new ArrayList<SearchBookContentsResult>(count);
199         for (int x = 0; x < count; x++) {
200           items.add(parseResult(results.getJSONObject(x)));
201         }
202         resultListView.setOnItemClickListener(new BrowseBookListener(this, items));
203         resultListView.setAdapter(new SearchBookContentsAdapter(this, items));
204       } else {
205         String searchable = json.optString("searchable");
206         if ("false".equals(searchable)) {
207           headerView.setText(R.string.msg_sbc_book_not_searchable);
208         }
209         resultListView.setAdapter(null);
210       }
211     } catch (JSONException e) {
212       Log.w(TAG, "Bad JSON from book search", e);
213       resultListView.setAdapter(null);
214       headerView.setText(R.string.msg_sbc_failed);
215     }
216   }
217
218   // Available fields: page_id, page_number, page_url, snippet_text
219   private SearchBookContentsResult parseResult(JSONObject json) {
220     try {
221       String pageId = json.getString("page_id");
222       String pageNumber = json.getString("page_number");
223       if (pageNumber.length() > 0) {
224         pageNumber = getString(R.string.msg_sbc_page) + ' ' + pageNumber;
225       } else {
226         // This can happen for text on the jacket, and possibly other reasons.
227         pageNumber = getString(R.string.msg_sbc_unknown_page);
228       }
229
230       // Remove all HTML tags and encoded characters. Ideally the server would do this.
231       String snippet = json.optString("snippet_text");
232       boolean valid = true;
233       if (snippet.length() > 0) {
234         snippet = TAG_PATTERN.matcher(snippet).replaceAll("");
235         snippet = LT_ENTITY_PATTERN.matcher(snippet).replaceAll("<");
236         snippet = GT_ENTITY_PATTERN.matcher(snippet).replaceAll(">");
237         snippet = QUOTE_ENTITY_PATTERN.matcher(snippet).replaceAll("'");
238         snippet = QUOT_ENTITY_PATTERN.matcher(snippet).replaceAll("\"");
239       } else {
240         snippet = '(' + getString(R.string.msg_sbc_snippet_unavailable) + ')';
241         valid = false;
242       }
243       return new SearchBookContentsResult(pageId, pageNumber, snippet, valid);
244     } catch (JSONException e) {
245       // Never seen in the wild, just being complete.
246       return new SearchBookContentsResult(getString(R.string.msg_sbc_no_page_returned), "", "", false);
247     }
248   }
249
250   private static final class NetworkThread extends Thread {
251     private final String isbn;
252     private final String query;
253     private final Handler handler;
254
255     NetworkThread(String isbn, String query, Handler handler) {
256       this.isbn = isbn;
257       this.query = query;
258       this.handler = handler;
259     }
260
261     @Override
262     public void run() {
263       AndroidHttpClient client = null;
264       try {
265         // These return a JSON result which describes if and where the query was found. This API may
266         // break or disappear at any time in the future. Since this is an API call rather than a
267         // website, we don't use LocaleManager to change the TLD.
268         URI uri;
269         if (isbn.startsWith("http://google.com/books?id=")) {
270           int equals = isbn.indexOf('=');
271           String volumeId = isbn.substring(equals + 1);
272           uri = new URI("http", null, "www.google.com", -1, "/books", "id=" + volumeId +
273                         "&jscmd=SearchWithinVolume2&q=" + query, null);
274         } else {
275           uri = new URI("http", null, "www.google.com", -1, "/books", "vid=isbn" + isbn +
276                         "&jscmd=SearchWithinVolume2&q=" + query, null);
277         }
278         HttpUriRequest get = new HttpGet(uri);
279         get.setHeader("cookie", getCookie(uri.toString()));
280         client = AndroidHttpClient.newInstance(USER_AGENT);
281         HttpResponse response = client.execute(get);
282         if (response.getStatusLine().getStatusCode() == 200) {
283           HttpEntity entity = response.getEntity();
284           ByteArrayOutputStream jsonHolder = new ByteArrayOutputStream();
285           entity.writeTo(jsonHolder);
286           jsonHolder.flush();
287           JSONObject json = new JSONObject(jsonHolder.toString(getEncoding(entity)));
288           jsonHolder.close();
289
290           Message message = Message.obtain(handler, R.id.search_book_contents_succeeded);
291           message.obj = json;
292           message.sendToTarget();
293         } else {
294           Log.w(TAG, "HTTP returned " + response.getStatusLine().getStatusCode() + " for " + uri);
295           Message message = Message.obtain(handler, R.id.search_book_contents_failed);
296           message.sendToTarget();
297         }
298       } catch (Exception e) {
299         Log.w(TAG, "Error accessing book search", e);
300         Message message = Message.obtain(handler, R.id.search_book_contents_failed);
301         message.sendToTarget();
302       } finally {
303         if (client != null) {
304           client.close();
305         }
306       }
307     }
308
309     // Book Search requires a cookie to work, which we store persistently. If the cookie does
310     // not exist, this could be the first search or it has expired. Either way, do a quick HEAD
311     // request to fetch it, save it via the CookieSyncManager to flash, then return it.
312     private static String getCookie(String url) {
313       String cookie = CookieManager.getInstance().getCookie(url);
314       if (cookie == null || cookie.length() == 0) {
315         Log.d(TAG, "Book Search cookie was missing or expired");
316         HttpUriRequest head = new HttpHead(url);
317         AndroidHttpClient client = AndroidHttpClient.newInstance(USER_AGENT);
318         try {
319           HttpResponse response = client.execute(head);
320           if (response.getStatusLine().getStatusCode() == 200) {
321             Header[] cookies = response.getHeaders("set-cookie");
322             for (Header theCookie : cookies) {
323               CookieManager.getInstance().setCookie(url, theCookie.getValue());
324             }
325             CookieSyncManager.getInstance().sync();
326             cookie = CookieManager.getInstance().getCookie(url);
327           }
328         } catch (IOException e) {
329           Log.w(TAG, "Error setting book search cookie", e);
330         }
331         client.close();
332       }
333       return cookie;
334     }
335
336     private static String getEncoding(HttpEntity entity) {
337       // FIXME: The server is returning ISO-8859-1 but the content is actually windows-1252.
338       // Once Jeff fixes the HTTP response, remove this hardcoded value and go back to getting
339       // the encoding dynamically.
340       return "windows-1252";
341 //            HeaderElement[] elements = entity.getContentType().getElements();
342 //            if (elements != null && elements.length > 0) {
343 //                String encoding = elements[0].getParameterByName("charset").getValue();
344 //                if (encoding != null && encoding.length() > 0) {
345 //                    return encoding;
346 //                }
347 //            }
348 //            return "UTF-8";
349     }
350   }
351 }