/** * WikiUtils.java * Copyright (C)2009 Thomas Hirsch * Geohashdroid Copyright (C)2009 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENSE file at the toplevel. */ package net.exclaimindustries.geohashdroid.wiki; import android.content.Context; import android.location.Location; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.format.DateFormat; import android.util.Log; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.util.Graticule; import net.exclaimindustries.geohashdroid.util.Info; import net.exclaimindustries.geohashdroid.util.UnitConverter; import net.exclaimindustries.tools.DOMUtil; import net.exclaimindustries.tools.DateTools; import org.w3c.dom.Document; import org.w3c.dom.Element; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URLEncoder; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.ArrayList; import java.util.HashMap; import java.util.Locale; import javax.xml.parsers.DocumentBuilderFactory; import cz.msebera.android.httpclient.HttpEntity; import cz.msebera.android.httpclient.HttpResponse; import cz.msebera.android.httpclient.NameValuePair; import cz.msebera.android.httpclient.client.entity.UrlEncodedFormEntity; import cz.msebera.android.httpclient.client.methods.HttpGet; import cz.msebera.android.httpclient.client.methods.HttpPost; import cz.msebera.android.httpclient.client.methods.HttpUriRequest; import cz.msebera.android.httpclient.entity.ContentType; import cz.msebera.android.httpclient.entity.mime.MultipartEntityBuilder; import cz.msebera.android.httpclient.entity.mime.content.ByteArrayBody; import cz.msebera.android.httpclient.entity.mime.content.StringBody; import cz.msebera.android.httpclient.impl.client.CloseableHttpClient; import cz.msebera.android.httpclient.message.BasicNameValuePair; /** * Various stateless utility methods to query a mediawiki server */ public class WikiUtils { /** * The base URL for all wiki activities. Remember the trailing slash! */ private static final String WIKI_BASE_URL = "http://wiki.xkcd.com/"; /** * The URL for the MediaWiki API. There's no trailing slash here. */ private static final String WIKI_API_URL = WIKI_BASE_URL + "wgh/api.php"; /** * The base URL for viewing pages on the wiki. On the Geohashing wiki, the * URL where the API is located isn't what the public sees as the URL for * viewing pages, thus we need this. There IS a trailing slash. */ private static final String WIKI_BASE_VIEW_URL = WIKI_BASE_URL + "geohashing/"; private static final String DEBUG_TAG = "WikiUtils"; // The most recent request issued by WikiUtils. This allows the abort() // method to work. private static HttpUriRequest mLastRequest; /** * This format is used for all latitude/longitude texts in the wiki. */ public static final DecimalFormat mLatLonFormat = new DecimalFormat("###.0000", new DecimalFormatSymbols(Locale.US)); /** * This format is used for all latitude/longitude <i>links</i> in the wiki. * This differs from mLatLonFormat in that it doesn't clip values to four * decimal points. */ protected static final DecimalFormat mLatLonLinkFormat = new DecimalFormat("###.00000000", new DecimalFormatSymbols(Locale.US)); /** * Aborts the current wiki request. Well, technically, it's the most recent * wiki request. If it's already done, nothing happens. This will, of * course, cause exceptions in whatever's servicing the request. */ public static void abort() { if(mLastRequest != null) mLastRequest.abort(); } /** * Returns the wiki view URL. Attach a wiki page name to this to send it to * a browser for viewing. It will most likely be different from the API * URL. * * @return the wiki view URL */ @NonNull public static String getWikiBaseViewUrl() { return WIKI_BASE_VIEW_URL; } /** * Returns the URL for the MediaWiki API. This is where any queries should * go, in standard HTTP query form. * * @return the MediaWiki API URL */ @NonNull public static String getWikiApiUrl() { return WIKI_API_URL; } /** * Returns the content of a http request as an XML Document. This is to be * used only when we know the response to a request will be XML. Otherwise, * this will probably throw an exception. * * @param httpclient an active HTTP session * @param httpreq an HTTP request (GET or POST) * @return a Document containing the contents of the response */ private static Document getHttpDocument(@NonNull CloseableHttpClient httpclient, @NonNull HttpUriRequest httpreq) throws Exception { // Remember the last request. We might want to abort it later. mLastRequest = httpreq; HttpResponse response = httpclient.execute(httpreq); HttpEntity entity = response.getEntity(); return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(entity.getContent()); } /** * Returns whether or not a given wiki page or file exists. * * @param httpclient an active HTTP session * @param pagename the name of the wiki page * @return true if the page exists, false if not * @throws WikiException problem with the wiki, translate the ID * @throws Exception anything else happened, use getMessage */ public static boolean doesWikiPageExist(@NonNull CloseableHttpClient httpclient, @NonNull String pagename) throws Exception { // It's GET time! This is basically the same as the content request, but // we really don't need ANY data other than whether or not the page // exists, so we won't call for anything. HttpGet httpget = new HttpGet(WIKI_API_URL + "?action=query&format=xml&titles=" + URLEncoder.encode(pagename, "UTF-8")); Document response = getHttpDocument(httpclient, httpget); // Now for some of the usual checking that should look familiar... Element root = response.getDocumentElement(); // Error check! if(doesResponseHaveError(root)) { throw new WikiException(getErrorTextId(findErrorCode(root))); } Element pageElem; try { pageElem = DOMUtil.getFirstElement(root, "page"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } // "invalid" or "missing" both resolve to the same answer: No. Anything // else means yes. return !(pageElem.hasAttribute("invalid") || pageElem.hasAttribute("missing")); } /** * Returns the raw content of a wiki page in a single string. Optionally, * also attaches the fields for future resubmission to a HashMap (namely, an * edittoken and a timestamp). * * @param httpclient an active HTTP session * @param pagename the name of the wiki page * @param formfields if not null, this hashmap will be filled with the correct HTML form fields to resubmit the page. * @return the raw code of the wiki page, or null if the page doesn't exist * @throws WikiException problem with the wiki, translate the ID * @throws Exception anything else happened, use getMessage */ public static String getWikiPage(@NonNull CloseableHttpClient httpclient, @NonNull String pagename, @Nullable HashMap<String, String> formfields) throws Exception { // We can use a GET statement here. HttpGet httpget = new HttpGet(WIKI_API_URL + "?action=query&format=xml&prop=" + URLEncoder.encode("info|revisions", "UTF-8") + "&rvprop=content&format=xml&intoken=edit&titles=" + URLEncoder.encode(pagename, "UTF-8")); String page; Document response = getHttpDocument(httpclient, httpget); // Good, good. First, figure out if the page even exists. Element root = response.getDocumentElement(); // Error check! if(doesResponseHaveError(root)) { throw new WikiException(getErrorTextId(findErrorCode(root))); } Element pageElem; Element text; try { pageElem = DOMUtil.getFirstElement(root, "page"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } // If we got an "invalid" attribute, the page not only doesn't exist, // but it CAN'T exist, and is therefore an error. if(pageElem.hasAttribute("invalid")) throw new WikiException(R.string.wiki_error_invalid_page); if(formfields != null) { // If we have a formfields hash ready, populate it with a couple // values. formfields.put("summary", "An expedition message sent via Geohash Droid for Android."); if(pageElem.hasAttribute("edittoken")) formfields.put("token", DOMUtil.getSimpleAttributeText(pageElem, "edittoken")); if(pageElem.hasAttribute("touched")) formfields.put("basetimestamp", DOMUtil.getSimpleAttributeText(pageElem, "touched")); } // If we got a "missing" attribute, the page hasn't been made yet, so we // return null. if(pageElem.hasAttribute("missing")) return null; // Otherwise, get the text and fill out the form fields. try { text = DOMUtil.getFirstElement(pageElem, "rev"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } page = DOMUtil.getSimpleElementText(text); return page; } /** * Replaces an entire wiki page * * @param httpclient an active HTTP session * @param pagename the name of the wiki page * @param content the new content of the wiki page to be submitted * @param formfields a hashmap with the fields needed (besides pagename and content; those will be filled in this method) * @throws WikiException problem with the wiki, translate the ID * @throws Exception anything else happened, use getMessage */ public static void putWikiPage(@NonNull CloseableHttpClient httpclient, @NonNull String pagename, String content, @NonNull HashMap<String, String> formfields) throws Exception { // If there's no edit token in the hash map, we can't do anything. if(!formfields.containsKey("token")) { throw new WikiException(R.string.wiki_error_protected); } HttpPost httppost = new HttpPost(WIKI_API_URL); ArrayList<NameValuePair> nvps = new ArrayList<>(); nvps.add(new BasicNameValuePair("action", "edit")); nvps.add(new BasicNameValuePair("title", pagename)); nvps.add(new BasicNameValuePair("text", content)); nvps.add(new BasicNameValuePair("format", "xml")); for(String s : formfields.keySet()) { nvps.add(new BasicNameValuePair(s, formfields.get(s))); } httppost.setEntity(new UrlEncodedFormEntity(nvps, "utf-8")); Document response = getHttpDocument(httpclient, httppost); Element root = response.getDocumentElement(); // First, check for errors. if(doesResponseHaveError(root)) { throw new WikiException(getErrorTextId(findErrorCode(root))); } // And really, that's it. We're done! } /** * Uploads an image to the wiki * * @param httpclient an active HTTP session, wiki login has to have happened before. * @param filename the name of the new image file * @param description the description of the image. An initial description will be used as page content for the image's wiki page * @param formfields a formfields hash as modified by getWikiPage containing an edittoken we can use (see the MediaWiki API for reasons why) * @param data a ByteArray containing the raw image data (assuming jpeg encoding, currently). */ public static void putWikiImage(@NonNull CloseableHttpClient httpclient, @NonNull String filename, @NonNull String description, @NonNull HashMap<String, String> formfields, @NonNull byte[] data) throws Exception { if(!formfields.containsKey("token")) { throw new WikiException(R.string.wiki_error_unknown); } HttpPost httppost = new HttpPost(WIKI_API_URL); // First, we need an edit token. Let's get one. ArrayList<NameValuePair> tnvps = new ArrayList<>(); tnvps.add(new BasicNameValuePair("action", "query")); tnvps.add(new BasicNameValuePair("prop", "info")); tnvps.add(new BasicNameValuePair("intoken", "edit")); tnvps.add(new BasicNameValuePair("titles", "UPLOAD_AN_IMAGE")); tnvps.add(new BasicNameValuePair("format", "xml")); httppost.setEntity(new UrlEncodedFormEntity(tnvps, "utf-8")); Document response = getHttpDocument(httpclient, httppost); Element root = response.getDocumentElement(); // Hopefully, a token exists. If not, a problem exists. String token; Element page; try { page = DOMUtil.getFirstElement(root, "page"); token = DOMUtil.getSimpleAttributeText(page, "edittoken"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } // We very much need an edit token here. if(token == null) { throw new WikiException(R.string.wiki_error_xml); } // TOKEN GET! Now we've got us enough to get our upload on! MultipartEntityBuilder builder = MultipartEntityBuilder.create() .addPart("action", new StringBody("upload", ContentType.TEXT_PLAIN)) .addPart("filename", new StringBody(filename, ContentType.TEXT_PLAIN)) .addPart("comment", new StringBody(description, ContentType.TEXT_PLAIN)) .addPart("watch", new StringBody("true", ContentType.TEXT_PLAIN)) .addPart("ignorewarnings", new StringBody("true", ContentType.TEXT_PLAIN)) .addPart("token", new StringBody(token, ContentType.TEXT_PLAIN)) .addPart("format", new StringBody("xml", ContentType.TEXT_PLAIN)) .addPart("file", new ByteArrayBody(data, ContentType.create("image/jpeg", "utf-8"), filename)); httppost.setEntity(builder.build()); response = getHttpDocument(httpclient, httppost); root = response.getDocumentElement(); // First, check for errors. if(doesResponseHaveError(root)) { throw new WikiException(getErrorTextId(findErrorCode(root))); } } /** * Retrieves valid login cookies for an HTTP session. These will be added * to the CloseableHttpClient value passed in, so re-use it for future wiki * transactions. * * @param httpclient an active HTTP session. * @param wpName a wiki user name. * @param wpPassword the matching password to this user name. * @throws WikiException problem with the wiki, translate the ID * @throws Exception anything else happened, use getMessage */ public static void login(@NonNull CloseableHttpClient httpclient, @NonNull String wpName, @NonNull String wpPassword) throws Exception { HttpPost httppost = new HttpPost(WIKI_API_URL); ArrayList<NameValuePair> nvps = new ArrayList<>(); nvps.add(new BasicNameValuePair("action", "login")); nvps.add(new BasicNameValuePair("lgname", wpName)); nvps.add(new BasicNameValuePair("lgpassword", wpPassword)); nvps.add(new BasicNameValuePair("format", "xml")); httppost.setEntity(new UrlEncodedFormEntity(nvps, "utf-8")); Log.d(DEBUG_TAG, "Trying login..."); Document response = getHttpDocument(httpclient, httppost); // The result comes in as an XML chunk. Since we're expecting the // cookies to be set properly, all we care about is the "result" // attribute of the "login" element. Element root = response.getDocumentElement(); Element login; String result; try { login = DOMUtil.getFirstElement(root, "login"); result = DOMUtil.getSimpleAttributeText(login, "result"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } // Now, get the result. If it was a success, cookies got added. If it // was NeedToken, this is a 1.16 wiki (as it should be now) and we need // another request to get the final token. if(result != null && result.equals("NeedToken")) { Log.d(DEBUG_TAG, "Token needed, trying again..."); // Okay, do the same thing again, this time with the token we got // the first time around. Cookies will be set this time around, I // think. String token = DOMUtil.getSimpleAttributeText(login, "token"); httppost = new HttpPost(WIKI_API_URL); nvps = new ArrayList<>(); nvps.add(new BasicNameValuePair("action", "login")); nvps.add(new BasicNameValuePair("lgname", wpName)); nvps.add(new BasicNameValuePair("lgpassword", wpPassword)); nvps.add(new BasicNameValuePair("lgtoken", token)); nvps.add(new BasicNameValuePair("format", "xml")); httppost.setEntity(new UrlEncodedFormEntity(nvps, "utf-8")); Log.d(DEBUG_TAG, "Sending it out..."); response = getHttpDocument(httpclient, httppost); Log.d(DEBUG_TAG, "Response has returned!"); // Again! root = response.getDocumentElement(); try { login = DOMUtil.getFirstElement(root, "login"); result = DOMUtil.getSimpleAttributeText(login, "result"); } catch(Exception e) { throw new WikiException(R.string.wiki_error_xml); } } // Check it. If NeedToken was returned again, then the wiki is just // telling us nonsense and we've got a right to throw an exception. if(result != null && result.equals("Success")) { Log.d(DEBUG_TAG, "Success!"); } else { Log.d(DEBUG_TAG, "FAILURE!"); throw new WikiException(getErrorTextId(result)); } } /** * Gets the text ID that corresponds to a given error code. If the code * isn't recognized, this returns wiki_error_unknown instead. Note that * this WON'T understand a non-error condition; check to make sure it isn't * first. * * @param code String returned from the wiki * @return text ID that corresponds to that error */ private static int getErrorTextId(@Nullable String code) { // If we don't recognize the error (or shouldn't get it at all), we use // this, because we don't have the slightest clue what's wrong. int error = R.string.wiki_error_unknown; if(code == null) return error; // First, general errors. These are the only general ones we care // about; there's more, but those aren't likely to come up. switch(code) { case "unsupportednamespace": error = R.string.wiki_error_illegal_namespace; break; case "protectednamespace-interface": case "protectednamespace": case "customcssjsprotected": case "cascadeprotected": case "protectedpage": error = R.string.wiki_error_protected; break; case "confirmemail": error = R.string.wiki_error_email_confirm; break; case "permissiondenied": error = R.string.wiki_error_permission_denied; break; case "blocked": case "autoblocked": error = R.string.wiki_error_blocked; break; case "ratelimited": error = R.string.wiki_error_rate_limit; break; case "readonly": error = R.string.wiki_error_read_only; break; // Then, login errors. These come from the result attribute. case "Illegal": case "NoName": case "CreateBlocked": error = R.string.wiki_error_bad_username; break; case "NotExists": error = R.string.wiki_error_username_nonexistant; break; case "EmptyPass": case "WrongPass": case "WrongPluginPass": error = R.string.wiki_error_bad_password; break; case "Throttled": error = R.string.wiki_error_throttled; break; // Next, edit errors. These come from the error element, code // attribute. case "protectedtitle": error = R.string.wiki_error_protected; break; case "cantcreate": case "cantcreate-anon": error = R.string.wiki_error_no_create; break; case "spamdetected": error = R.string.wiki_error_spam; break; case "filtered": error = R.string.wiki_error_filtered; break; case "contenttoobig": error = R.string.wiki_error_too_big; break; case "noedit": case "noedit-anon": error = R.string.wiki_error_no_edit; break; case "editconflict": error = R.string.wiki_error_conflict; break; // If all else fails, log what we got. default: Log.d(DEBUG_TAG, "Unknown error code came back: " + code); break; } return error; } private static boolean doesResponseHaveError(@Nullable Element elem) { try { DOMUtil.getFirstElement(elem, "error"); } catch(Exception ex) { return false; } return true; } private static String findErrorCode(@Nullable Element elem) { try { Element error = DOMUtil.getFirstElement(elem, "error"); return DOMUtil.getSimpleAttributeText(error, "code"); } catch(Exception ex) { return "UnknownError"; } } /** * Retrieves the wiki page name for the given data. This accounts for * globalhashes, too. * * @param info Info from which a page name will be derived * @return said pagename */ public static String getWikiPageName(@NonNull Info info) { String date = DateTools.getHyphenatedDateString(info.getCalendar()); Graticule g = info.getGraticule(); if(g == null) { return date + "_global"; } else { String lat = g.getLatitudeString(true); String lon = g.getLongitudeString(true); return date + "_" + lat + "_" + lon; } } /** * <p> * Retrieves the text for the Expedition template appropriate for the given * Info. * </p> * * <p> * TODO: The wiki doesn't appear to have an Expedition template for * globalhashing yet. * </p> * * @param info Info from which an Expedition template will be generated * @param c Context so we can grab the globalhash template if we need it * @return said template */ public static String getWikiExpeditionTemplate(@NonNull Info info, @NonNull Context c) { String date = DateTools.getHyphenatedDateString(info.getCalendar()); Graticule g = info.getGraticule(); if(g == null) { // Until a proper template can be made in the wiki itself, we'll // have to settle for this... InputStream is = c.getResources().openRawResource(R.raw.globalhash_template); InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr); // Now, read in each line and do all substitutions on it. String input; StringBuilder toReturn = new StringBuilder(); try { while((input = br.readLine()) != null) { input = input.replaceAll("%%LATITUDE%%", UnitConverter.makeLatitudeCoordinateString(c, info.getLatitude(), true, UnitConverter.OUTPUT_DETAILED)); input = input.replaceAll("%%LONGITUDE%%", UnitConverter.makeLongitudeCoordinateString(c, info.getLongitude(), true, UnitConverter.OUTPUT_DETAILED)); input = input.replaceAll("%%LATITUDEURL%%", Double.valueOf(info.getLatitude()).toString()); input = input.replaceAll("%%LONGITUDEURL%%", Double.valueOf(info.getLongitude()).toString()); input = input.replaceAll("%%DATENUMERIC%%", date); input = input.replaceAll("%%DATESHORT%%", DateFormat.format("E MMM d yyyy", info.getCalendar()).toString()); input = input.replaceAll("%%DATEGOOGLE%%", DateFormat.format("d+MMM+yyyy", info.getCalendar()).toString()); toReturn.append(input).append("\n"); } } catch(IOException e) { // Don't do anything; just assume we're done. } return toReturn.toString() + getWikiCategories(info); } else { String lat = g.getLatitudeString(true); String lon = g.getLongitudeString(true); return "{{subst:Expedition|lat=" + lat + "|lon=" + lon + "|date=" + date + "}}"; } } /** * Retrieves the text for the categories to put on the wiki for pictures. * * @param info Info from which categories will be generated * @return said categories */ public static String getWikiCategories(@NonNull Info info) { String date = DateTools.getHyphenatedDateString(info.getCalendar()); String toReturn = "[[Category:Meetup on " + date + "]]\n"; Graticule g = info.getGraticule(); if(g == null) { return toReturn + "[[Category:Globalhash]]"; } else { String lat = g.getLatitudeString(true); String lon = g.getLongitudeString(true); return toReturn + "[[Category:Meetup in " + lat + " " + lon + "]]"; } } /** * Makes a location tag for the wiki that links to OpenStreetMap. Or just * returns an empty string if you gave it a null location. That's entirely * valid; if the user's location isn't known, the tag should be empty. * * @param loc the Location * @return an OpenStreetMap wiki tag */ public static String makeLocationTag(@Nullable Location loc) { if(loc != null) { return " [http://www.openstreetmap.org/?lat=" + mLatLonLinkFormat.format(loc.getLatitude()) + "&lon=" + mLatLonLinkFormat.format(loc.getLongitude()) + "&zoom=16&layers=B000FTF @" + mLatLonFormat.format(loc.getLatitude()) + "," + mLatLonFormat.format(loc.getLongitude()) + "]"; } else { return ""; } } }