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