/Sky/Sky/src/org/jsharkey/sky/WebserviceHelper.java

http://android-sky.googlecode.com/ · Java · 416 lines · 265 code · 65 blank · 86 comment · 57 complexity · b85a699ae96a30babf986443a2700d60 MD5 · raw file

  1. /*
  2. * Copyright (C) 2009 Jeff Sharkey, http://jsharkey.org/
  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 org.jsharkey.sky;
  17. import java.io.IOException;
  18. import java.io.InputStreamReader;
  19. import java.io.Reader;
  20. import java.io.StringReader;
  21. import java.util.ArrayList;
  22. import java.util.Collections;
  23. import java.util.Comparator;
  24. import java.util.HashMap;
  25. import java.util.List;
  26. import java.util.Map;
  27. import org.apache.http.HttpEntity;
  28. import org.apache.http.HttpResponse;
  29. import org.apache.http.StatusLine;
  30. import org.apache.http.client.HttpClient;
  31. import org.apache.http.client.methods.HttpGet;
  32. import org.apache.http.impl.client.DefaultHttpClient;
  33. import org.jsharkey.sky.ForecastProvider.AppWidgets;
  34. import org.jsharkey.sky.ForecastProvider.AppWidgetsColumns;
  35. import org.jsharkey.sky.ForecastProvider.Forecasts;
  36. import org.jsharkey.sky.ForecastProvider.ForecastsColumns;
  37. import org.xmlpull.v1.XmlPullParser;
  38. import org.xmlpull.v1.XmlPullParserException;
  39. import org.xmlpull.v1.XmlPullParserFactory;
  40. import android.content.ContentResolver;
  41. import android.content.ContentValues;
  42. import android.content.Context;
  43. import android.database.Cursor;
  44. import android.net.Uri;
  45. import android.text.Editable;
  46. import android.text.SpannableStringBuilder;
  47. import android.text.TextUtils;
  48. import android.text.format.DateUtils;
  49. import android.text.format.Time;
  50. import android.util.Log;
  51. import android.util.TimeFormatException;
  52. /**
  53. * Helper class to handle querying a webservice for forecast details and parsing
  54. * results into {@link ForecastProvider}.
  55. */
  56. public class WebserviceHelper {
  57. private static final String TAG = "ForcastHelper";
  58. private static final String[] PROJECTION_APPWIDGET = {
  59. AppWidgetsColumns.LAT,
  60. AppWidgetsColumns.LON,
  61. };
  62. private static final int COL_LAT = 0;
  63. private static final int COL_LON = 1;
  64. static final boolean FAKE_DATA = false;
  65. static final String WEBSERVICE_URL = "http://www.weather.gov/forecasts/xml/sample_products/browser_interface/ndfdBrowserClientByDay.php?&lat=%f&lon=%f&format=24+hourly&numDays=%d";
  66. /**
  67. * Timeout to wait for webservice to respond. Because we're in the
  68. * background, we don't mind waiting for good data.
  69. */
  70. static final long WEBSERVICE_TIMEOUT = 30 * DateUtils.SECOND_IN_MILLIS;
  71. /**
  72. * Various XML tags present in the response.
  73. */
  74. private static final String TAG_TEMPERATURE = "temperature";
  75. private static final String TAG_WEATHER = "weather";
  76. private static final String TAG_POP = "probability-of-precipitation";
  77. private static final String TAG_HAZARDS = "hazards";
  78. private static final String TAG_WEATHER_CONDITIONS = "weather-conditions";
  79. private static final String TAG_HAZARD = "hazard";
  80. private static final String TAG_LAYOUT_KEY = "layout-key";
  81. private static final String TAG_START_VALID_TIME = "start-valid-time";
  82. private static final String TAG_VALUE = "value";
  83. private static final String TAG_HAZARDTEXTURL = "hazardTextURL";
  84. private static final String TAG_MOREWEATHERINFORMATION = "moreWeatherInformation";
  85. /**
  86. * Various XML attributes present in the response.
  87. */
  88. private static final String ATTR_TIME_LAYOUT = "time-layout";
  89. private static final String ATTR_TYPE = "type";
  90. private static final String ATTR_WEATHER_SUMMARY = "weather-summary";
  91. private static final String ATTR_PHENOMENA = "phenomena";
  92. private static final String ATTR_SIGNIFICANCE = "significance";
  93. private static final String TYPE_MAXIMUM = "maximum";
  94. private static final String TYPE_MINIMUM = "minimum";
  95. private static final String EXAMPLE_RESPONSE = "<?xml version=\"1.0\"?><dwml version=\"1.0\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://www.nws.noaa.gov/forecasts/xml/DWMLgen/schema/DWML.xsd\"><head><product srsName=\"WGS 1984\" concise-name=\"dwmlByDay\" operational-mode=\"official\"><title>NOAA's National Weather Service Forecast by 24 Hour Period</title><field>meteorological</field><category>forecast</category><creation-date refresh-frequency=\"PT1H\">2009-03-22T22:56:32Z</creation-date></product><source><more-information>http://www.nws.noaa.gov/forecasts/xml/</more-information><production-center>Meteorological Development Laboratory<sub-center>Product Generation Branch</sub-center></production-center><disclaimer>http://www.nws.noaa.gov/disclaimer.html</disclaimer><credit>http://www.weather.gov/</credit><credit-logo>http://www.weather.gov/images/xml_logo.gif</credit-logo><feedback>http://www.weather.gov/feedback.php</feedback></source></head><data><location><location-key>point1</location-key><point latitude=\"39.91\" longitude=\"-121.29\"/></location><moreWeatherInformation applicable-location=\"point1\">http://forecast.weather.gov/MapClick.php?textField1=39.91&amp;textField2=-121.29</moreWeatherInformation><time-layout time-coordinate=\"local\" summarization=\"24hourly\"><layout-key>k-p24h-n4-1</layout-key><start-valid-time>2009-03-22T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-23T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-23T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-24T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-24T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-25T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-25T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-26T06:00:00-07:00</end-valid-time></time-layout><time-layout time-coordinate=\"local\" summarization=\"12hourly\"><layout-key>k-p12h-n8-2</layout-key><start-valid-time>2009-03-22T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-22T18:00:00-07:00</end-valid-time><start-valid-time>2009-03-22T18:00:00-07:00</start-valid-time><end-valid-time>2009-03-23T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-23T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-23T18:00:00-07:00</end-valid-time><start-valid-time>2009-03-23T18:00:00-07:00</start-valid-time><end-valid-time>2009-03-24T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-24T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-24T18:00:00-07:00</end-valid-time><start-valid-time>2009-03-24T18:00:00-07:00</start-valid-time><end-valid-time>2009-03-25T06:00:00-07:00</end-valid-time><start-valid-time>2009-03-25T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-25T18:00:00-07:00</end-valid-time><start-valid-time>2009-03-25T18:00:00-07:00</start-valid-time><end-valid-time>2009-03-26T06:00:00-07:00</end-valid-time></time-layout><time-layout time-coordinate=\"local\" summarization=\"24hourly\"><layout-key>k-p4d-n1-3</layout-key><start-valid-time>2009-03-22T06:00:00-07:00</start-valid-time><end-valid-time>2009-03-26T06:00:00-07:00</end-valid-time></time-layout><parameters applicable-location=\"point1\"><temperature type=\"maximum\" units=\"Fahrenheit\" time-layout=\"k-p24h-n4-1\"><name>Daily Maximum Temperature</name><value>32</value><value>47</value><value>55</value><value>58</value></temperature><temperature type=\"minimum\" units=\"Fahrenheit\" time-layout=\"k-p24h-n4-1\"><name>Daily Minimum Temperature</name><value>24</value><value>28</value><value>32</value><value>31</value></temperature><probability-of-precipitation type=\"12 hour\" units=\"percent\" time-layout=\"k-p12h-n8-2\"><name>12 Hourly Probability of Precipitation</name><value>98</value><value>22</value><value>6</value><value>6</value><value>4</value><value>0</value><value>16</value><value>18</value></probability-of-precipitation><weather time-layout=\"k-p24h-n4-1\"><name>Weather Type, Coverage, and Intensity</name><weather-conditions weather-summary=\"Slight Chance Snow Showers\"><value coverage=\"slight chance\" intensity=\"light\" weather-type=\"snow showers\" qualifier=\"none\"/></weather-conditions><weather-conditions weather-summary=\"Partly Cloudy\"/><weather-conditions weather-summary=\"Mostly Sunny\"/><weather-conditions weather-summary=\"Partly Cloudy\"/></weather><conditions-icon type=\"forecast-NWS\" time-layout=\"k-p24h-n4-1\"><name>Conditions Icons</name><icon-link>http://www.nws.noaa.gov/weather/images/fcicons/sn100.jpg</icon-link><icon-link>http://www.nws.noaa.gov/weather/images/fcicons/sct.jpg</icon-link><icon-link>http://www.nws.noaa.gov/weather/images/fcicons/few.jpg</icon-link><icon-link>http://www.nws.noaa.gov/weather/images/fcicons/sct.jpg</icon-link></conditions-icon><hazards time-layout=\"k-p4d-n1-3\"><name>Watches, Warnings, and Advisories</name><hazard-conditions><hazard hazardCode=\"LW.Y\" phenomena=\"Lake Wind\" significance=\"Advisory\" hazardType=\"long duration\"><hazardTextURL>http://forecast.weather.gov/wwamap/wwatxtget.php?cwa=usa&amp;wwa=Lake%20Wind%20Advisory</hazardTextURL></hazard></hazard-conditions></hazards></parameters></data></dwml>";
  96. /**
  97. * Recycled string builder used by {@link #parseDate(String)}.
  98. */
  99. private static Editable sEditable = new SpannableStringBuilder();
  100. /**
  101. * Recycled timestamp used by {@link #parseDate(String)}.
  102. */
  103. private static Time sTime = new Time();
  104. /**
  105. * Exception to inform callers that we ran into problems while parsing the
  106. * forecast returned by the webservice.
  107. */
  108. public static final class ForecastParseException extends Exception {
  109. public ForecastParseException(String detailMessage) {
  110. super(detailMessage);
  111. }
  112. public ForecastParseException(String detailMessage, Throwable throwable) {
  113. super(detailMessage, throwable);
  114. }
  115. }
  116. /**
  117. * Parse a NWS date string into a Unix timestamp. Assumes incoming values
  118. * are in the format "2009-03-23T18:00:00-07:00", which we adjust slightly
  119. * to correctly follow RFC 3339 before parsing.
  120. */
  121. private static long parseDate(String raw) throws TimeFormatException {
  122. // Inject milliseconds so that NWS dates follow RFC
  123. sEditable.clear();
  124. sEditable.append(raw);
  125. sEditable.insert(19, ".000");
  126. String rfcFormat = sEditable.toString();
  127. sTime.parse3339(rfcFormat);
  128. return sTime.toMillis(false);
  129. }
  130. /**
  131. * Class holding a specific forecast at a point in time.
  132. */
  133. private static class Forecast {
  134. boolean alert = false;
  135. long validStart = Long.MIN_VALUE;
  136. int tempHigh = Integer.MIN_VALUE;
  137. int tempLow = Integer.MIN_VALUE;
  138. String conditions;
  139. String url;
  140. }
  141. /**
  142. * Retrieve a specific {@link Forecast} object from the given {@link Map}
  143. * structure. If the {@link Forecast} doesn't exist, it's created and
  144. * returned.
  145. */
  146. private static Forecast getForecast(Map<String, List<Forecast>> forecasts,
  147. String layout, int index) {
  148. if (!forecasts.containsKey(layout)) {
  149. forecasts.put(layout, new ArrayList<Forecast>());
  150. }
  151. List<Forecast> layoutSpecific = forecasts.get(layout);
  152. while (index >= layoutSpecific.size()) {
  153. layoutSpecific.add(new Forecast());
  154. }
  155. return layoutSpecific.get(index);
  156. }
  157. /**
  158. * Flatten a set of {@link Forecast} objects that are separated into
  159. * <code>time-layout</code> sections in the given {@link Map}. This discards
  160. * any forecasts that have empty {@link Forecast#conditions}.
  161. * <p>
  162. * Sorts the resulting list by time, with any alerts forced to the top.
  163. */
  164. private static List<Forecast> flattenForecasts(Map<String, List<Forecast>> forecasts) {
  165. List<Forecast> flat = new ArrayList<Forecast>();
  166. // Collect together all forecasts that have valid conditions
  167. for (String layout : forecasts.keySet()) {
  168. for (Forecast forecast : forecasts.get(layout)) {
  169. if (!TextUtils.isEmpty(forecast.conditions)) {
  170. flat.add(forecast);
  171. }
  172. }
  173. }
  174. // Sort by time, but always bump alerts to top
  175. Collections.sort(flat, new Comparator<Forecast>() {
  176. public int compare(Forecast left, Forecast right) {
  177. if (left.alert) {
  178. return -1;
  179. } else {
  180. return (int)(left.validStart - right.validStart);
  181. }
  182. }
  183. });
  184. return flat;
  185. }
  186. /**
  187. * Perform a webservice query to retrieve and store the forecast for the
  188. * given widget. This call blocks until request is finished and
  189. * {@link Forecasts#CONTENT_URI} has been updated.
  190. */
  191. public static void updateForecasts(Context context, Uri appWidgetUri, int days)
  192. throws ForecastParseException {
  193. Uri appWidgetForecasts = Uri.withAppendedPath(appWidgetUri, AppWidgets.TWIG_FORECASTS);
  194. ContentResolver resolver = context.getContentResolver();
  195. Cursor cursor = null;
  196. double lat = Double.NaN;
  197. double lon = Double.NaN;
  198. // Pull exact forecast location from database
  199. try {
  200. cursor = resolver.query(appWidgetUri, PROJECTION_APPWIDGET, null, null, null);
  201. if (cursor != null && cursor.moveToFirst()) {
  202. lat = cursor.getDouble(COL_LAT);
  203. lon = cursor.getDouble(COL_LON);
  204. }
  205. } finally {
  206. if (cursor != null) {
  207. cursor.close();
  208. }
  209. }
  210. // Query webservice for this location
  211. List<Forecast> forecasts = queryLocation(lat, lon, days);
  212. if (forecasts == null || forecasts.size() == 0) {
  213. throw new ForecastParseException("No forecasts found from webservice query");
  214. }
  215. // Purge existing forecasts covered by incoming data, and anything
  216. // before today
  217. long lastMidnight = ForecastUtils.getLastMidnight();
  218. long earliest = Long.MAX_VALUE;
  219. for (Forecast forecast : forecasts) {
  220. earliest = Math.min(earliest, forecast.validStart);
  221. }
  222. resolver.delete(appWidgetForecasts,
  223. ForecastsColumns.VALID_START + " >= " + earliest + " OR " +
  224. ForecastsColumns.VALID_START + " <= " + lastMidnight, null);
  225. // Insert any new forecasts found
  226. ContentValues values = new ContentValues();
  227. for (Forecast forecast : forecasts) {
  228. Log.d(TAG, "inserting forecast with validStart=" + forecast.validStart);
  229. values.clear();
  230. values.put(ForecastsColumns.VALID_START, forecast.validStart);
  231. values.put(ForecastsColumns.TEMP_HIGH, forecast.tempHigh);
  232. values.put(ForecastsColumns.TEMP_LOW, forecast.tempLow);
  233. values.put(ForecastsColumns.CONDITIONS, forecast.conditions);
  234. values.put(ForecastsColumns.URL, forecast.url);
  235. if (forecast.alert) {
  236. values.put(ForecastsColumns.ALERT, ForecastsColumns.ALERT_TRUE);
  237. }
  238. resolver.insert(appWidgetForecasts, values);
  239. }
  240. // Mark widget cache as being updated
  241. values.clear();
  242. values.put(AppWidgetsColumns.LAST_UPDATED, System.currentTimeMillis());
  243. resolver.update(appWidgetUri, values, null, null);
  244. }
  245. /**
  246. * Query the given location and parse any returned data into a set of
  247. * {@link Forecast} objects. This is a blocking call while waiting for the
  248. * webservice to return.
  249. */
  250. private static List<Forecast> queryLocation(double lat, double lon, int days)
  251. throws ForecastParseException {
  252. if (Double.isNaN(lat) || Double.isNaN(lon)) {
  253. throw new ForecastParseException("Requested forecast for invalid location");
  254. } else {
  255. Log.d(TAG, String.format("queryLocation() with lat=%f, lon=%f, days=%d", lat, lon, days));
  256. }
  257. Reader responseReader;
  258. List<Forecast> forecasts = null;
  259. if (FAKE_DATA) {
  260. // Feed back fake data, if requested
  261. responseReader = new StringReader(EXAMPLE_RESPONSE);
  262. } else {
  263. // Perform webservice query and parse result
  264. HttpClient client = new DefaultHttpClient();
  265. HttpGet request = new HttpGet(String.format(WEBSERVICE_URL, lat, lon, days));
  266. try {
  267. HttpResponse response = client.execute(request);
  268. StatusLine status = response.getStatusLine();
  269. Log.d(TAG, "Request returned status " + status);
  270. HttpEntity entity = response.getEntity();
  271. responseReader = new InputStreamReader(entity.getContent());
  272. } catch (IOException e) {
  273. throw new ForecastParseException("Problem calling forecast API", e);
  274. }
  275. }
  276. // If response found, send through to parser
  277. if (responseReader != null) {
  278. forecasts = parseResponse(responseReader);
  279. }
  280. return forecasts;
  281. }
  282. /**
  283. * Parse a webservice XML response into {@link Forecast} objects.
  284. */
  285. private static List<Forecast> parseResponse(Reader response)
  286. throws ForecastParseException {
  287. // Keep a temporary mapping between time series tags and forecasts
  288. Map<String, List<Forecast>> forecasts = new HashMap<String, List<Forecast>>();
  289. String detailsUrl = null;
  290. try {
  291. XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
  292. XmlPullParser xpp = factory.newPullParser();
  293. int index = 0;
  294. String thisTag = null;
  295. String thisLayout = null;
  296. String thisType = null;
  297. xpp.setInput(response);
  298. int eventType = xpp.getEventType();
  299. while (eventType != XmlPullParser.END_DOCUMENT) {
  300. if (eventType == XmlPullParser.START_TAG) {
  301. thisTag = xpp.getName();
  302. if (TAG_TEMPERATURE.equals(thisTag) || TAG_WEATHER.equals(thisTag)
  303. || TAG_POP.equals(thisTag) || TAG_HAZARDS.equals(thisTag)) {
  304. thisLayout = xpp.getAttributeValue(null, ATTR_TIME_LAYOUT);
  305. thisType = xpp.getAttributeValue(null, ATTR_TYPE);
  306. index = -1;
  307. } else if (TAG_WEATHER_CONDITIONS.equals(thisTag)) {
  308. Forecast forecast = getForecast(forecasts, thisLayout, ++index);
  309. forecast.conditions = xpp.getAttributeValue(null, ATTR_WEATHER_SUMMARY);
  310. } else if (TAG_HAZARD.equals(thisTag)) {
  311. Forecast forecast = getForecast(forecasts, thisLayout, ++index);
  312. forecast.alert = true;
  313. forecast.conditions = xpp.getAttributeValue(null, ATTR_PHENOMENA) + " "
  314. + xpp.getAttributeValue(null, ATTR_SIGNIFICANCE);
  315. }
  316. } else if (eventType == XmlPullParser.END_TAG) {
  317. thisTag = null;
  318. } else if (eventType == XmlPullParser.TEXT) {
  319. if (TAG_LAYOUT_KEY.equals(thisTag)) {
  320. thisLayout = xpp.getText();
  321. index = -1;
  322. } else if (TAG_START_VALID_TIME.equals(thisTag)) {
  323. Forecast forecast = getForecast(forecasts, thisLayout, ++index);
  324. forecast.validStart = parseDate(xpp.getText());
  325. } else if (TAG_VALUE.equals(thisTag) && TYPE_MAXIMUM.equals(thisType)) {
  326. Forecast forecast = getForecast(forecasts, thisLayout, ++index);
  327. forecast.tempHigh = Integer.parseInt(xpp.getText());
  328. forecast.url = detailsUrl;
  329. } else if (TAG_VALUE.equals(thisTag) && TYPE_MINIMUM.equals(thisType)) {
  330. Forecast forecast = getForecast(forecasts, thisLayout, ++index);
  331. forecast.tempLow = Integer.parseInt(xpp.getText());
  332. } else if (TAG_HAZARDTEXTURL.equals(thisTag)) {
  333. Forecast forecast = getForecast(forecasts, thisLayout, index);
  334. forecast.url = xpp.getText();
  335. } else if (TAG_MOREWEATHERINFORMATION.equals(thisTag)) {
  336. detailsUrl = xpp.getText();
  337. }
  338. }
  339. eventType = xpp.next();
  340. }
  341. } catch (IOException e) {
  342. throw new ForecastParseException("Problem parsing XML forecast", e);
  343. } catch (XmlPullParserException e) {
  344. throw new ForecastParseException("Problem parsing XML forecast", e);
  345. } catch (TimeFormatException e) {
  346. throw new ForecastParseException("Problem parsing XML forecast", e);
  347. }
  348. // Flatten non-empty forecasts into single list
  349. return flattenForecasts(forecasts);
  350. }
  351. }