diff options
author | The Android Open Source Project <initial-contribution@android.com> | 2009-03-03 19:31:44 -0800 |
---|---|---|
committer | The Android Open Source Project <initial-contribution@android.com> | 2009-03-03 19:31:44 -0800 |
commit | 9066cfe9886ac131c34d59ed0e2d287b0e3c0087 (patch) | |
tree | d88beb88001f2482911e3d28e43833b50e4b4e97 /core/java/com/google/android/util | |
parent | d83a98f4ce9cfa908f5c54bbd70f03eec07e7553 (diff) | |
download | frameworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.zip frameworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.tar.gz frameworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.tar.bz2 |
auto import from //depot/cupcake/@135843
Diffstat (limited to 'core/java/com/google/android/util')
6 files changed, 2342 insertions, 0 deletions
diff --git a/core/java/com/google/android/util/AbstractMessageParser.java b/core/java/com/google/android/util/AbstractMessageParser.java new file mode 100644 index 0000000..25f6b33 --- /dev/null +++ b/core/java/com/google/android/util/AbstractMessageParser.java @@ -0,0 +1,1496 @@ +// Copyright 2007 The Android Open Source Project +// All Rights Reserved. + +package com.google.android.util; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Set; +import java.util.List; + +/** + * + * Logic for parsing a text message typed by the user looking for smileys, + * urls, acronyms,formatting (e.g., '*'s for bold), me commands + * (e.g., "/me is asleep"), and punctuation. + * + * It constructs an array, which breaks the text up into its + * constituent pieces, which we return to the client. + * + */ +public abstract class AbstractMessageParser { +/** + * Interface representing the set of resources needed by a message parser + * + * @author jessan (Jessan Hutchison-Quillian) + */ + public static interface Resources { + + /** Get the known set of URL schemes. */ + public Set<String> getSchemes(); + + /** Get the possible values for the last part of a domain name. + * Values are expected to be reversed in the Trie. + */ + public TrieNode getDomainSuffixes(); + + /** Get the smileys accepted by the parser. */ + public TrieNode getSmileys(); + + /** Get the acronyms accepted by the parser. */ + public TrieNode getAcronyms(); + } + + /** + * Subclasses must define the schemes, domains, smileys and acronyms + * that are necessary for parsing + */ + protected abstract Resources getResources(); + + /** Music note that indicates user is listening to a music track. */ + public static final String musicNote = "\u266B "; + + private String text; + private int nextChar; + private int nextClass; + private ArrayList<Part> parts; + private ArrayList<Token> tokens; + private HashMap<Character,Format> formatStart; + private boolean parseSmilies; + private boolean parseAcronyms; + private boolean parseFormatting; + private boolean parseUrls; + private boolean parseMeText; + private boolean parseMusic; + + /** + * Create a message parser to parse urls, formatting, acronyms, smileys, + * /me text and music + * + * @param text the text to parse + */ + public AbstractMessageParser(String text) { + this(text, true, true, true, true, true, true); + } + + /** + * Create a message parser, specifying the kinds of text to parse + * + * @param text the text to parse + * + */ + public AbstractMessageParser(String text, boolean parseSmilies, + boolean parseAcronyms, boolean parseFormatting, boolean parseUrls, + boolean parseMusic, boolean parseMeText) { + this.text = text; + this.nextChar = 0; + this.nextClass = 10; + this.parts = new ArrayList<Part>(); + this.tokens = new ArrayList<Token>(); + this.formatStart = new HashMap<Character,Format>(); + this.parseSmilies = parseSmilies; + this.parseAcronyms = parseAcronyms; + this.parseFormatting = parseFormatting; + this.parseUrls = parseUrls; + this.parseMusic = parseMusic; + this.parseMeText = parseMeText; + } + + /** Returns the raw text being parsed. */ + public final String getRawText() { return text; } + + /** Return the number of parts. */ + public final int getPartCount() { return parts.size(); } + + /** Return the part at the given index. */ + public final Part getPart(int index) { return parts.get(index); } + + /** Return the list of parts from the parsed text */ + public final List<Part> getParts() { return parts; } + + /** Parses the text string into an internal representation. */ + public void parse() { + // Look for music track (of which there would be only one and it'll be the + // first token) + if (parseMusicTrack()) { + buildParts(null); + return; + } + + // Look for me commands. + String meText = null; + if (parseMeText && text.startsWith("/me") && (text.length() > 3) && + Character.isWhitespace(text.charAt(3))) { + meText = text.substring(0, 4); + text = text.substring(4); + } + + // Break the text into tokens. + boolean wasSmiley = false; + while (nextChar < text.length()) { + if (!isWordBreak(nextChar)) { + if (!wasSmiley || !isSmileyBreak(nextChar)) { + throw new AssertionError("last chunk did not end at word break"); + } + } + + if (parseSmiley()) { + wasSmiley = true; + } else { + wasSmiley = false; + + if (!parseAcronym() && !parseURL() && !parseFormatting()) { + parseText(); + } + } + } + + // Trim the whitespace before and after media components. + for (int i = 0; i < tokens.size(); ++i) { + if (tokens.get(i).isMedia()) { + if ((i > 0) && (tokens.get(i - 1) instanceof Html)) { + ((Html)tokens.get(i - 1)).trimLeadingWhitespace(); + } + if ((i + 1 < tokens.size()) && (tokens.get(i + 1) instanceof Html)) { + ((Html)tokens.get(i + 1)).trimTrailingWhitespace(); + } + } + } + + // Remove any empty html tokens. + for (int i = 0; i < tokens.size(); ++i) { + if (tokens.get(i).isHtml() && + (tokens.get(i).toHtml(true).length() == 0)) { + tokens.remove(i); + --i; // visit this index again + } + } + + buildParts(meText); + } + + /** + * Get a the appropriate Token for a given URL + * + * @param text the anchor text + * @param url the url + * + */ + public static Token tokenForUrl(String url, String text) { + if(url == null) { + return null; + } + + //Look for video links + Video video = Video.matchURL(url, text); + if (video != null) { + return video; + } + + // Look for video links. + YouTubeVideo ytVideo = YouTubeVideo.matchURL(url, text); + if (ytVideo != null) { + return ytVideo; + } + + // Look for photo links. + Photo photo = Photo.matchURL(url, text); + if (photo != null) { + return photo; + } + + // Look for photo links. + FlickrPhoto flickrPhoto = FlickrPhoto.matchURL(url, text); + if (flickrPhoto != null) { + return flickrPhoto; + } + + //Not media, so must be a regular URL + return new Link(url, text); + } + + /** + * Builds the parts list. + * + * @param meText any meText parsed from the message + */ + private void buildParts(String meText) { + for (int i = 0; i < tokens.size(); ++i) { + Token token = tokens.get(i); + if (token.isMedia() || (parts.size() == 0) || lastPart().isMedia()) { + parts.add(new Part()); + } + lastPart().add(token); + } + + // The first part inherits the meText of the line. + if (parts.size() > 0) { + parts.get(0).setMeText(meText); + } + } + + /** Returns the last part in the list. */ + private Part lastPart() { return parts.get(parts.size() - 1); } + + /** + * Looks for a music track (\u266B is first character, everything else is + * track info). + */ + private boolean parseMusicTrack() { + + if (parseMusic && text.startsWith(musicNote)) { + addToken(new MusicTrack(text.substring(musicNote.length()))); + nextChar = text.length(); + return true; + } + return false; + } + + /** Consumes all of the text in the next word . */ + private void parseText() { + StringBuilder buf = new StringBuilder(); + int start = nextChar; + do { + char ch = text.charAt(nextChar++); + switch (ch) { + case '<': buf.append("<"); break; + case '>': buf.append(">"); break; + case '&': buf.append("&"); break; + case '"': buf.append("""); break; + case '\'': buf.append("'"); break; + case '\n': buf.append("<br>"); break; + default: buf.append(ch); break; + } + } while (!isWordBreak(nextChar)); + + addToken(new Html(text.substring(start, nextChar), buf.toString())); + } + + /** + * Looks for smileys (e.g., ":)") in the text. The set of known smileys is + * loaded from a file into a trie at server start. + */ + private boolean parseSmiley() { + if(!parseSmilies) { + return false; + } + TrieNode match = longestMatch(getResources().getSmileys(), this, nextChar, + true); + if (match == null) { + return false; + } else { + int previousCharClass = getCharClass(nextChar - 1); + int nextCharClass = getCharClass(nextChar + match.getText().length()); + if ((previousCharClass == 2 || previousCharClass == 3) + && (nextCharClass == 2 || nextCharClass == 3)) { + return false; + } + addToken(new Smiley(match.getText())); + nextChar += match.getText().length(); + return true; + } + } + + /** Looks for acronyms (e.g., "lol") in the text. + */ + private boolean parseAcronym() { + if(!parseAcronyms) { + return false; + } + TrieNode match = longestMatch(getResources().getAcronyms(), this, nextChar); + if (match == null) { + return false; + } else { + addToken(new Acronym(match.getText(), match.getValue())); + nextChar += match.getText().length(); + return true; + } + } + + /** Determines if this is an allowable domain character. */ + private boolean isDomainChar(char c) { + return c == '-' || Character.isLetter(c) || Character.isDigit(c); + } + + /** Determines if the given string is a valid domain. */ + private boolean isValidDomain(String domain) { + // For hostnames, check that it ends with a known domain suffix + if (matches(getResources().getDomainSuffixes(), reverse(domain))) { + return true; + } + return false; + } + + /** + * Looks for a URL in two possible forms: either a proper URL with a known + * scheme or a domain name optionally followed by a path, query, or query. + */ + private boolean parseURL() { + // Make sure this is a valid place to start a URL. + if (!parseUrls || !isURLBreak(nextChar)) { + return false; + } + + int start = nextChar; + + // Search for the first block of letters. + int index = start; + while ((index < text.length()) && isDomainChar(text.charAt(index))) { + index += 1; + } + + String url = ""; + boolean done = false; + + if (index == text.length()) { + return false; + } else if (text.charAt(index) == ':') { + // Make sure this is a known scheme. + String scheme = text.substring(nextChar, index); + if (!getResources().getSchemes().contains(scheme)) { + return false; + } + } else if (text.charAt(index) == '.') { + // Search for the end of the domain name. + while (index < text.length()) { + char ch = text.charAt(index); + if ((ch != '.') && !isDomainChar(ch)) { + break; + } else { + index += 1; + } + } + + // Make sure the domain name has a valid suffix. Since tries look for + // prefix matches, we reverse all the strings to get suffix comparisons. + String domain = text.substring(nextChar, index); + if (!isValidDomain(domain)) { + return false; + } + + // Search for a port. We deal with this specially because a colon can + // also be a punctuation character. + if ((index + 1 < text.length()) && (text.charAt(index) == ':')) { + char ch = text.charAt(index + 1); + if (Character.isDigit(ch)) { + index += 1; + while ((index < text.length()) && + Character.isDigit(text.charAt(index))) { + index += 1; + } + } + } + + // The domain name should be followed by end of line, whitespace, + // punctuation, or a colon, slash, question, or hash character. The + // tricky part here is that some URL characters are also punctuation, so + // we need to distinguish them. Since we looked for ports above, a colon + // is always punctuation here. To distinguish '?' cases, we look at the + // character that follows it. + if (index == text.length()) { + done = true; + } else { + char ch = text.charAt(index); + if (ch == '?') { + // If the next character is whitespace or punctuation (or missing), + // then this question mark looks like punctuation. + if (index + 1 == text.length()) { + done = true; + } else { + char ch2 = text.charAt(index + 1); + if (Character.isWhitespace(ch2) || isPunctuation(ch2)) { + done = true; + } + } + } else if (isPunctuation(ch)) { + done = true; + } else if (Character.isWhitespace(ch)) { + done = true; + } else if ((ch == '/') || (ch == '#')) { + // In this case, the URL is not done. We will search for the end of + // it below. + } else { + return false; + } + } + + // We will assume the user meant HTTP. (One weird case is where they + // type a port of 443. That could mean HTTPS, but they might also want + // HTTP. We'll let them specify if they don't want HTTP.) + url = "http://"; + } else { + return false; + } + + // If the URL is not done, search for the end, which is just before the + // next whitespace character. + if (!done) { + while ((index < text.length()) && + !Character.isWhitespace(text.charAt(index))) { + index += 1; + } + } + + String urlText = text.substring(start, index); + url += urlText; + + // Figure out the appropriate token type. + addURLToken(url, urlText); + + nextChar = index; + return true; + } + + /** + * Adds the appropriate token for the given URL. This might be a simple + * link or it might be a recognized media type. + */ + private void addURLToken(String url, String text) { + addToken(tokenForUrl(url, text)); + } + + /** + * Deal with formatting characters. + * + * Parsing is as follows: + * - Treat all contiguous strings of formatting characters as one block. + * (This method processes one block.) + * - Only a single instance of a particular format character within a block + * is used to determine whether to turn on/off that type of formatting; + * other instances simply print the character itself. + * - If the format is to be turned on, we use the _first_ instance; if it + * is to be turned off, we use the _last_ instance (by appending the + * format.) + * + * Example: + * **string** turns into <b>*string*</b> + */ + private boolean parseFormatting() { + if(!parseFormatting) { + return false; + } + int endChar = nextChar; + while ((endChar < text.length()) && isFormatChar(text.charAt(endChar))) { + endChar += 1; + } + + if ((endChar == nextChar) || !isWordBreak(endChar)) { + return false; + } + + // Keeps track of whether we've seen a character (in map if we've seen it) + // and whether we should append a closing format token (if value in + // map is TRUE). Linked hashmap for consistent ordering. + LinkedHashMap<Character, Boolean> seenCharacters = + new LinkedHashMap<Character, Boolean>(); + + for (int index = nextChar; index < endChar; ++index) { + char ch = text.charAt(index); + Character key = Character.valueOf(ch); + if (seenCharacters.containsKey(key)) { + // Already seen this character, just append an unmatched token, which + // will print plaintext character + addToken(new Format(ch, false)); + } else { + Format start = formatStart.get(key); + if (start != null) { + // Match the start token, and ask an end token to be appended + start.setMatched(true); + formatStart.remove(key); + seenCharacters.put(key, Boolean.TRUE); + } else { + // Append start token + start = new Format(ch, true); + formatStart.put(key, start); + addToken(start); + seenCharacters.put(key, Boolean.FALSE); + } + } + } + + // Append any necessary end tokens + for (Character key : seenCharacters.keySet()) { + if (seenCharacters.get(key) == Boolean.TRUE) { + Format end = new Format(key.charValue(), false); + end.setMatched(true); + addToken(end); + } + } + + nextChar = endChar; + return true; + } + + /** Determines whether the given index could be a possible word break. */ + private boolean isWordBreak(int index) { + return getCharClass(index - 1) != getCharClass(index); + } + + /** Determines whether the given index could be a possible smiley break. */ + private boolean isSmileyBreak(int index) { + if (index > 0 && index < text.length()) { + if (isSmileyBreak(text.charAt(index - 1), text.charAt(index))) { + return true; + } + } + + return false; + } + + /** + * Verifies that the character before the given index is end of line, + * whitespace, or punctuation. + */ + private boolean isURLBreak(int index) { + switch (getCharClass(index - 1)) { + case 2: + case 3: + case 4: + return false; + + case 0: + case 1: + default: + return true; + } + } + + /** Returns the class for the character at the given index. */ + private int getCharClass(int index) { + if ((index < 0) || (text.length() <= index)) { + return 0; + } + + char ch = text.charAt(index); + if (Character.isWhitespace(ch)) { + return 1; + } else if (Character.isLetter(ch)) { + return 2; + } else if (Character.isDigit(ch)) { + return 3; + } else if (isPunctuation(ch)) { + // For punctuation, we return a unique value every time so that they are + // always different from any other character. Punctuation should always + // be considered a possible word break. + return ++nextClass; + } else { + return 4; + } + } + + /** + * Returns true if <code>c1</code> could be the last character of + * a smiley and <code>c2</code> could be the first character of + * a different smiley, if {@link #isWordBreak} would not already + * recognize that this is possible. + */ + private static boolean isSmileyBreak(char c1, char c2) { + switch (c1) { + /* + * These characters can end smileys, but don't normally end words. + */ + case '$': case '&': case '*': case '+': case '-': + case '/': case '<': case '=': case '>': case '@': + case '[': case '\\': case ']': case '^': case '|': + case '}': case '~': + switch (c2) { + /* + * These characters can begin smileys, but don't normally + * begin words. + */ + case '#': case '$': case '%': case '*': case '/': + case '<': case '=': case '>': case '@': case '[': + case '\\': case '^': case '~': + return true; + } + } + + return false; + } + + /** Determines whether the given character is punctuation. */ + private static boolean isPunctuation(char ch) { + switch (ch) { + case '.': case ',': case '"': case ':': case ';': + case '?': case '!': case '(': case ')': + return true; + + default: + return false; + } + } + + /** + * Determines whether the given character is the beginning or end of a + * section with special formatting. + */ + private static boolean isFormatChar(char ch) { + switch (ch) { + case '*': case '_': case '^': + return true; + + default: + return false; + } + } + + /** Represents a unit of parsed output. */ + public static abstract class Token { + public enum Type { + + HTML ("html"), + FORMAT ("format"), // subtype of HTML + LINK ("l"), + SMILEY ("e"), + ACRONYM ("a"), + MUSIC ("m"), + GOOGLE_VIDEO ("v"), + YOUTUBE_VIDEO ("yt"), + PHOTO ("p"), + FLICKR ("f"); + + //stringreps for HTML and FORMAT don't really matter + //because they don't define getInfo(), which is where it is used + //For the other types, code depends on their stringreps + private String stringRep; + + Type(String stringRep) { + this.stringRep = stringRep; + } + + /** {@inheritDoc} */ + public String toString() { + return this.stringRep; + } + } + + protected Type type; + protected String text; + + protected Token(Type type, String text) { + this.type = type; + this.text = text; + } + + /** Returns the type of the token. */ + public Type getType() { return type; } + + /** + * Get the relevant information about a token + * + * @return a list of strings representing the token, not null + * The first item is always a string representation of the type + */ + public List<String> getInfo() { + List<String> info = new ArrayList<String>(); + info.add(getType().toString()); + return info; + } + + /** Returns the raw text of the token. */ + public String getRawText() { return text; } + + public boolean isMedia() { return false; } + public abstract boolean isHtml(); + public boolean isArray() { return !isHtml(); } + + public String toHtml(boolean caps) { throw new AssertionError("not html"); } + + // The token can change the caps of the text after that point. + public boolean controlCaps() { return false; } + public boolean setCaps() { return false; } + } + + /** Represents a simple string of html text. */ + public static class Html extends Token { + private String html; + + public Html(String text, String html) { + super(Type.HTML, text); + this.html = html; + } + + public boolean isHtml() { return true; } + public String toHtml(boolean caps) { + return caps ? html.toUpperCase() : html; + } + /** + * Not supported. Info should not be needed for this type + */ + public List<String> getInfo() { + throw new UnsupportedOperationException(); + } + + public void trimLeadingWhitespace() { + text = trimLeadingWhitespace(text); + html = trimLeadingWhitespace(html); + } + + public void trimTrailingWhitespace() { + text = trimTrailingWhitespace(text); + html = trimTrailingWhitespace(html); + } + + private static String trimLeadingWhitespace(String text) { + int index = 0; + while ((index < text.length()) && + Character.isWhitespace(text.charAt(index))) { + ++index; + } + return text.substring(index); + } + + public static String trimTrailingWhitespace(String text) { + int index = text.length(); + while ((index > 0) && Character.isWhitespace(text.charAt(index - 1))) { + --index; + } + return text.substring(0, index); + } + } + + /** Represents a music track token at the beginning. */ + public static class MusicTrack extends Token { + private String track; + + public MusicTrack(String track) { + super(Type.MUSIC, track); + this.track = track; + } + + public String getTrack() { return track; } + + public boolean isHtml() { return false; } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getTrack()); + return info; + } + } + + /** Represents a link that was found in the input. */ + public static class Link extends Token { + private String url; + + public Link(String url, String text) { + super(Type.LINK, text); + this.url = url; + } + + public String getURL() { return url; } + + public boolean isHtml() { return false; } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getURL()); + info.add(getRawText()); + return info; + } + } + + /** Represents a link to a Google Video. */ + public static class Video extends Token { + /** Pattern for a video URL. */ + private static final Pattern URL_PATTERN = Pattern.compile( + "(?i)http://video\\.google\\.[a-z0-9]+(?:\\.[a-z0-9]+)?/videoplay\\?" + + ".*?\\bdocid=(-?\\d+).*"); + + private String docid; + + public Video(String docid, String text) { + super(Type.GOOGLE_VIDEO, text); + this.docid = docid; + } + + public String getDocID() { return docid; } + + public boolean isHtml() { return false; } + public boolean isMedia() { return true; } + + /** Returns a Video object if the given url is to a video. */ + public static Video matchURL(String url, String text) { + Matcher m = URL_PATTERN.matcher(url); + if (m.matches()) { + return new Video(m.group(1), text); + } else { + return null; + } + } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getRssUrl(docid)); + info.add(getURL(docid)); + return info; + } + + /** Returns the URL for the RSS description of the given video. */ + public static String getRssUrl(String docid) { + return "http://video.google.com/videofeed" + + "?type=docid&output=rss&sourceid=gtalk&docid=" + docid; + } + + /** (For testing purposes:) Returns a video URL with the given parts. */ + public static String getURL(String docid) { + return getURL(docid, null); + } + + /** (For testing purposes:) Returns a video URL with the given parts. */ + public static String getURL(String docid, String extraParams) { + if (extraParams == null) { + extraParams = ""; + } else if (extraParams.length() > 0) { + extraParams += "&"; + } + return "http://video.google.com/videoplay?" + extraParams + + "docid=" + docid; + } + } + + /** Represents a link to a YouTube video. */ + public static class YouTubeVideo extends Token { + /** Pattern for a video URL. */ + private static final Pattern URL_PATTERN = Pattern.compile( + "(?i)http://(?:[a-z0-9]+\\.)?youtube\\.[a-z0-9]+(?:\\.[a-z0-9]+)?/watch\\?" + + ".*\\bv=([-_a-zA-Z0-9=]+).*"); + + private String docid; + + public YouTubeVideo(String docid, String text) { + super(Type.YOUTUBE_VIDEO, text); + this.docid = docid; + } + + public String getDocID() { return docid; } + + public boolean isHtml() { return false; } + public boolean isMedia() { return true; } + + /** Returns a Video object if the given url is to a video. */ + public static YouTubeVideo matchURL(String url, String text) { + Matcher m = URL_PATTERN.matcher(url); + if (m.matches()) { + return new YouTubeVideo(m.group(1), text); + } else { + return null; + } + } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getRssUrl(docid)); + info.add(getURL(docid)); + return info; + } + + /** Returns the URL for the RSS description of the given video. */ + public static String getRssUrl(String docid) { + return "http://youtube.com/watch?v=" + docid; + } + + /** (For testing purposes:) Returns a video URL with the given parts. */ + public static String getURL(String docid) { + return getURL(docid, null); + } + + /** (For testing purposes:) Returns a video URL with the given parts. */ + public static String getURL(String docid, String extraParams) { + if (extraParams == null) { + extraParams = ""; + } else if (extraParams.length() > 0) { + extraParams += "&"; + } + return "http://youtube.com/watch?" + extraParams + "v=" + docid; + } + + /** (For testing purposes:) Returns a video URL with the given parts. + * @param http If true, includes http:// + * @param prefix If non-null/non-blank, adds to URL before youtube.com. + * (e.g., prefix="br." --> "br.youtube.com") + */ + public static String getPrefixedURL(boolean http, String prefix, + String docid, String extraParams) { + String protocol = ""; + + if (http) { + protocol = "http://"; + } + + if (prefix == null) { + prefix = ""; + } + + if (extraParams == null) { + extraParams = ""; + } else if (extraParams.length() > 0) { + extraParams += "&"; + } + + return protocol + prefix + "youtube.com/watch?" + extraParams + "v=" + + docid; + } + } + + /** Represents a link to a Picasa photo or album. */ + public static class Photo extends Token { + /** Pattern for an album or photo URL. */ + // TODO (katyarogers) searchbrowse includes search lists and tags, + // it follows a different pattern than albums - would be nice to add later + private static final Pattern URL_PATTERN = Pattern.compile( + "http://picasaweb.google.com/([^/?#&]+)/+((?!searchbrowse)[^/?#&]+)(?:/|/photo)?(?:\\?[^#]*)?(?:#(.*))?"); + + private String user; + private String album; + private String photo; // null for albums + + public Photo(String user, String album, String photo, String text) { + super(Type.PHOTO, text); + this.user = user; + this.album = album; + this.photo = photo; + } + + public String getUser() { return user; } + public String getAlbum() { return album; } + public String getPhoto() { return photo; } + + public boolean isHtml() { return false; } + public boolean isMedia() { return true; } + + /** Returns a Photo object if the given url is to a photo or album. */ + public static Photo matchURL(String url, String text) { + Matcher m = URL_PATTERN.matcher(url); + if (m.matches()) { + return new Photo(m.group(1), m.group(2), m.group(3), text); + } else { + return null; + } + } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getRssUrl(getUser())); + info.add(getAlbumURL(getUser(), getAlbum())); + if (getPhoto() != null) { + info.add(getPhotoURL(getUser(), getAlbum(), getPhoto())); + } else { + info.add((String)null); + } + return info; + } + + /** Returns the URL for the RSS description of the user's albums. */ + public static String getRssUrl(String user) { + return "http://picasaweb.google.com/data/feed/api/user/" + user + + "?category=album&alt=rss"; + } + + /** Returns the URL for an album. */ + public static String getAlbumURL(String user, String album) { + return "http://picasaweb.google.com/" + user + "/" + album; + } + + /** Returns the URL for a particular photo. */ + public static String getPhotoURL(String user, String album, String photo) { + return "http://picasaweb.google.com/" + user + "/" + album + "/photo#" + + photo; + } + } + + /** Represents a link to a Flickr photo or album. */ + public static class FlickrPhoto extends Token { + /** Pattern for a user album or photo URL. */ + private static final Pattern URL_PATTERN = Pattern.compile( + "http://(?:www.)?flickr.com/photos/([^/?#&]+)/?([^/?#&]+)?/?.*"); + private static final Pattern GROUPING_PATTERN = Pattern.compile( + "http://(?:www.)?flickr.com/photos/([^/?#&]+)/(tags|sets)/" + + "([^/?#&]+)/?"); + + private static final String SETS = "sets"; + private static final String TAGS = "tags"; + + private String user; + private String photo; // null for user album + private String grouping; // either "tags" or "sets" + private String groupingId; // sets or tags identifier + + public FlickrPhoto(String user, String photo, String grouping, + String groupingId, String text) { + super(Type.FLICKR, text); + + /* System wide tags look like the URL to a Flickr user. */ + if (!TAGS.equals(user)) { + this.user = user; + // Don't consider slide show URL a photo + this.photo = (!"show".equals(photo) ? photo : null); + this.grouping = grouping; + this.groupingId = groupingId; + } else { + this.user = null; + this.photo = null; + this.grouping = TAGS; + this.groupingId = photo; + } + } + + public String getUser() { return user; } + public String getPhoto() { return photo; } + public String getGrouping() { return grouping; } + public String getGroupingId() { return groupingId; } + + public boolean isHtml() { return false; } + public boolean isMedia() { return true; } + + /** + * Returns a FlickrPhoto object if the given url is to a photo or Flickr + * user. + */ + public static FlickrPhoto matchURL(String url, String text) { + Matcher m = GROUPING_PATTERN.matcher(url); + if (m.matches()) { + return new FlickrPhoto(m.group(1), null, m.group(2), m.group(3), text); + } + + m = URL_PATTERN.matcher(url); + if (m.matches()) { + return new FlickrPhoto(m.group(1), m.group(2), null, null, text); + } else { + return null; + } + } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getUrl()); + info.add(getUser() != null ? getUser() : ""); + info.add(getPhoto() != null ? getPhoto() : ""); + info.add(getGrouping() != null ? getGrouping() : ""); + info.add(getGroupingId() != null ? getGroupingId() : ""); + return info; + } + + public String getUrl() { + if (SETS.equals(grouping)) { + return getUserSetsURL(user, groupingId); + } else if (TAGS.equals(grouping)) { + if (user != null) { + return getUserTagsURL(user, groupingId); + } else { + return getTagsURL(groupingId); + } + } else if (photo != null) { + return getPhotoURL(user, photo); + } else { + return getUserURL(user); + } + } + + /** Returns the URL for the RSS description. */ + public static String getRssUrl(String user) { + return null; + } + + /** Returns the URL for a particular tag. */ + public static String getTagsURL(String tag) { + return "http://flickr.com/photos/tags/" + tag; + } + + /** Returns the URL to the user's Flickr homepage. */ + public static String getUserURL(String user) { + return "http://flickr.com/photos/" + user; + } + + /** Returns the URL for a particular photo. */ + public static String getPhotoURL(String user, String photo) { + return "http://flickr.com/photos/" + user + "/" + photo; + } + + /** Returns the URL for a user tag photo set. */ + public static String getUserTagsURL(String user, String tagId) { + return "http://flickr.com/photos/" + user + "/tags/" + tagId; + } + + /** Returns the URL for user set. */ + public static String getUserSetsURL(String user, String setId) { + return "http://flickr.com/photos/" + user + "/sets/" + setId; + } + } + + /** Represents a smiley that was found in the input. */ + public static class Smiley extends Token { + // TODO: Pass the SWF URL down to the client. + + public Smiley(String text) { + super(Type.SMILEY, text); + } + + public boolean isHtml() { return false; } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getRawText()); + return info; + } + } + + /** Represents an acronym that was found in the input. */ + public static class Acronym extends Token { + private String value; + // TODO: SWF + + public Acronym(String text, String value) { + super(Type.ACRONYM, text); + this.value = value; + } + + public String getValue() { return value; } + + public boolean isHtml() { return false; } + + public List<String> getInfo() { + List<String> info = super.getInfo(); + info.add(getRawText()); + info.add(getValue()); + return info; + } + } + + /** Represents a character that changes formatting. */ + public static class Format extends Token { + private char ch; + private boolean start; + private boolean matched; + + public Format(char ch, boolean start) { + super(Type.FORMAT, String.valueOf(ch)); + this.ch = ch; + this.start = start; + } + + public void setMatched(boolean matched) { this.matched = matched; } + + public boolean isHtml() { return true; } + + public String toHtml(boolean caps) { + // This character only implies special formatting if it was matched. + // Otherwise, it was just a plain old character. + if (matched) { + return start ? getFormatStart(ch) : getFormatEnd(ch); + } else { + // We have to make sure we escape HTML characters as usual. + return (ch == '"') ? """ : String.valueOf(ch); + } + } + + /** + * Not supported. Info should not be needed for this type + */ + public List<String> getInfo() { + throw new UnsupportedOperationException(); + } + + public boolean controlCaps() { return (ch == '^'); } + public boolean setCaps() { return start; } + + private String getFormatStart(char ch) { + switch (ch) { + case '*': return "<b>"; + case '_': return "<i>"; + case '^': return "<b><font color=\"#005FFF\">"; // TODO: all caps + case '"': return "<font color=\"#999999\">\u201c"; + default: throw new AssertionError("unknown format '" + ch + "'"); + } + } + + private String getFormatEnd(char ch) { + switch (ch) { + case '*': return "</b>"; + case '_': return "</i>"; + case '^': return "</font></b>"; // TODO: all caps + case '"': return "\u201d</font>"; + default: throw new AssertionError("unknown format '" + ch + "'"); + } + } + } + + /** Adds the given token to the parsed output. */ + private void addToken(Token token) { + tokens.add(token); + } + + /** Converts the entire message into a single HTML display string. */ + public String toHtml() { + StringBuilder html = new StringBuilder(); + + for (Part part : parts) { + boolean caps = false; + + html.append("<p>"); + for (Token token : part.getTokens()) { + if (token.isHtml()) { + html.append(token.toHtml(caps)); + } else { + switch (token.getType()) { + case LINK: + html.append("<a href=\""); + html.append(((Link)token).getURL()); + html.append("\">"); + html.append(token.getRawText()); + html.append("</a>"); + break; + + case SMILEY: + // TODO: link to an appropriate image + html.append(token.getRawText()); + break; + + case ACRONYM: + html.append(token.getRawText()); + break; + + case MUSIC: + // TODO: include a music glyph + html.append(((MusicTrack)token).getTrack()); + break; + + case GOOGLE_VIDEO: + // TODO: include a Google Video icon + html.append("<a href=\""); + html.append(((Video)token).getURL(((Video)token).getDocID())); + html.append("\">"); + html.append(token.getRawText()); + html.append("</a>"); + break; + + case YOUTUBE_VIDEO: + // TODO: include a YouTube icon + html.append("<a href=\""); + html.append(((YouTubeVideo)token).getURL( + ((YouTubeVideo)token).getDocID())); + html.append("\">"); + html.append(token.getRawText()); + html.append("</a>"); + break; + + case PHOTO: { + // TODO: include a Picasa Web icon + html.append("<a href=\""); + html.append(Photo.getAlbumURL( + ((Photo)token).getUser(), ((Photo)token).getAlbum())); + html.append("\">"); + html.append(token.getRawText()); + html.append("</a>"); + break; + } + + case FLICKR: + // TODO: include a Flickr icon + Photo p = (Photo) token; + html.append("<a href=\""); + html.append(((FlickrPhoto)token).getUrl()); + html.append("\">"); + html.append(token.getRawText()); + html.append("</a>"); + break; + + default: + throw new AssertionError("unknown token type: " + token.getType()); + } + } + + if (token.controlCaps()) { + caps = token.setCaps(); + } + } + html.append("</p>\n"); + } + + return html.toString(); + } + + /** Returns the reverse of the given string. */ + protected static String reverse(String str) { + StringBuilder buf = new StringBuilder(); + for (int i = str.length() - 1; i >= 0; --i) { + buf.append(str.charAt(i)); + } + return buf.toString(); + } + + public static class TrieNode { + private final HashMap<Character,TrieNode> children = + new HashMap<Character,TrieNode>(); + private String text; + private String value; + + public TrieNode() { this(""); } + public TrieNode(String text) { + this.text = text; + } + + public final boolean exists() { return value != null; } + public final String getText() { return text; } + public final String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public TrieNode getChild(char ch) { + return children.get(Character.valueOf(ch)); + } + + public TrieNode getOrCreateChild(char ch) { + Character key = Character.valueOf(ch); + TrieNode node = children.get(key); + if (node == null) { + node = new TrieNode(text + String.valueOf(ch)); + children.put(key, node); + } + return node; + } + + /** Adds the given string into the trie. */ + public static void addToTrie(TrieNode root, String str, String value) { + int index = 0; + while (index < str.length()) { + root = root.getOrCreateChild(str.charAt(index++)); + } + root.setValue(value); + } + } + + + + /** Determines whether the given string is in the given trie. */ + private static boolean matches(TrieNode root, String str) { + int index = 0; + while (index < str.length()) { + root = root.getChild(str.charAt(index++)); + if (root == null) { + break; + } else if (root.exists()) { + return true; + } + } + return false; + } + + /** + * Returns the longest substring of the given string, starting at the given + * index, that exists in the trie. + */ + private static TrieNode longestMatch( + TrieNode root, AbstractMessageParser p, int start) { + return longestMatch(root, p, start, false); + } + + /** + * Returns the longest substring of the given string, starting at the given + * index, that exists in the trie, with a special tokenizing case for + * smileys if specified. + */ + private static TrieNode longestMatch( + TrieNode root, AbstractMessageParser p, int start, boolean smiley) { + int index = start; + TrieNode bestMatch = null; + while (index < p.getRawText().length()) { + root = root.getChild(p.getRawText().charAt(index++)); + if (root == null) { + break; + } else if (root.exists()) { + if (p.isWordBreak(index)) { + bestMatch = root; + } else if (smiley && p.isSmileyBreak(index)) { + bestMatch = root; + } + } + } + return bestMatch; + } + + + /** Represents set of tokens that are delivered as a single message. */ + public static class Part { + private String meText; + private ArrayList<Token> tokens; + + public Part() { + this.tokens = new ArrayList<Token>(); + } + + public String getType(boolean isSend) { + return (isSend ? "s" : "r") + getPartType(); + } + + private String getPartType() { + if (isMedia()) { + return "d"; + } else if (meText != null) { + return "m"; + } else { + return ""; + } + } + + public boolean isMedia() { + return (tokens.size() == 1) && tokens.get(0).isMedia(); + } + /** + * Convenience method for getting the Token of a Part that represents + * a media Token. Parts of this kind will always only have a single Token + * + * @return if this.isMedia(), + * returns the Token representing the media contained in this Part, + * otherwise returns null; + */ + public Token getMediaToken() { + if(isMedia()) { + return tokens.get(0); + } + return null; + } + + /** Adds the given token to this part. */ + public void add(Token token) { + if (isMedia()) { + throw new AssertionError("media "); + } + tokens.add(token); + } + + public void setMeText(String meText) { + this.meText = meText; + } + + /** Returns the original text of this part. */ + public String getRawText() { + StringBuilder buf = new StringBuilder(); + if (meText != null) { + buf.append(meText); + } + for (int i = 0; i < tokens.size(); ++i) { + buf.append(tokens.get(i).getRawText()); + } + return buf.toString(); + } + + /** Returns the tokens in this part. */ + public ArrayList<Token> getTokens() { return tokens; } + + /** Adds the tokens into the given builder as an array. */ +// public void toArray(JSArrayBuilder array) { +// if (isMedia()) { +// // For media, we send its array (i.e., we don't wrap this in another +// // array as we do for non-media parts). +// tokens.get(0).toArray(array); +// } else { +// array.beginArray(); +// addToArray(array); +// array.endArray(); +// } +// } + } +} diff --git a/core/java/com/google/android/util/GoogleWebContentHelper.java b/core/java/com/google/android/util/GoogleWebContentHelper.java new file mode 100644 index 0000000..2911420 --- /dev/null +++ b/core/java/com/google/android/util/GoogleWebContentHelper.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.http.SslError; +import android.os.Message; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.HttpAuthHandler; +import android.webkit.SslErrorHandler; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.TextView; + +import java.util.Locale; + +/** + * Helper to display Google web content, and fallback on a static message if the + * web content is unreachable. For example, this can be used to display + * "Legal terms". + * <p> + * The typical usage pattern is to have two Gservices settings defined: + * <ul> + * <li>A secure URL that will be displayed on the device. This should be HTTPS + * so hotspots won't intercept it giving us a false positive that the page + * loaded successfully. + * <li>A pretty human-readable URL that will be displayed to the user in case we + * cannot reach the above URL. + * </ul> + * <p> + * The typical call sequence is {@link #setUrlsFromGservices(String, String)}, + * {@link #setUnsuccessfulMessage(String)}, and {@link #loadUrl()}. At some + * point, you'll want to display the layout via {@link #getLayout()}. + */ +public class GoogleWebContentHelper { + + private Context mContext; + + private String mSecureUrl; + private String mPrettyUrl; + + private String mUnsuccessfulMessage; + + private ViewGroup mLayout; + private WebView mWebView; + private View mProgressBar; + private TextView mTextView; + + private boolean mReceivedResponse; + + public GoogleWebContentHelper(Context context) { + mContext = context; + } + + /** + * Fetches the URLs from Gservices. + * + * @param secureSetting The setting key whose value contains the HTTPS URL. + * @param prettySetting The setting key whose value contains the pretty URL. + * @return This {@link GoogleWebContentHelper} so methods can be chained. + */ + public GoogleWebContentHelper setUrlsFromGservices(String secureSetting, String prettySetting) { + ContentResolver contentResolver = mContext.getContentResolver(); + mSecureUrl = fillUrl(Settings.Gservices.getString(contentResolver, secureSetting), + mContext); + mPrettyUrl = fillUrl(Settings.Gservices.getString(contentResolver, prettySetting), + mContext); + return this; + } + + /** + * Fetch directly from provided urls. + * + * @param secureUrl The HTTPS URL. + * @param prettyUrl The pretty URL. + * @return This {@link GoogleWebContentHelper} so methods can be chained. + */ + public GoogleWebContentHelper setUrls(String secureUrl, String prettyUrl) { + mSecureUrl = fillUrl(secureUrl, mContext); + mPrettyUrl = fillUrl(prettyUrl, mContext); + return this; + } + + + /** + * Sets the message that will be shown if we are unable to load the page. + * <p> + * This should be called after {@link #setUrlsFromGservices(String, String)} + * . + * + * @param message The message to load. The first argument, according to + * {@link java.util.Formatter}, will be substituted with the pretty + * URL. + * @return This {@link GoogleWebContentHelper} so methods can be chained. + */ + public GoogleWebContentHelper setUnsuccessfulMessage(String message) { + Locale locale = mContext.getResources().getConfiguration().locale; + mUnsuccessfulMessage = String.format(locale, message, mPrettyUrl); + return this; + } + + /** + * Begins loading the secure URL. + * + * @return This {@link GoogleWebContentHelper} so methods can be chained. + */ + public GoogleWebContentHelper loadUrl() { + ensureViews(); + mWebView.loadUrl(mSecureUrl); + return this; + } + + /** + * Helper to handle the back key. Returns true if the back key was handled, + * otherwise returns false. + * @param event the key event sent to {@link Activity#dispatchKeyEvent()} + */ + public boolean handleKey(KeyEvent event) { + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK + && event.getAction() == KeyEvent.ACTION_DOWN) { + if (mWebView.canGoBack()) { + mWebView.goBack(); + return true; + } + } + return false; + } + + /** + * Returns the layout containing the web view, progress bar, and text view. + * This class takes care of setting each one's visibility based on current + * state. + * + * @return The layout you should display. + */ + public ViewGroup getLayout() { + ensureViews(); + return mLayout; + } + + private synchronized void ensureViews() { + if (mLayout == null) { + initializeViews(); + } + } + + /** + * Fills the URL with the locale. + * + * @param url The URL in Formatter style for the extra info to be filled in. + * @return The filled URL. + */ + private static String fillUrl(String url, Context context) { + + if (TextUtils.isEmpty(url)) { + return ""; + } + + /* We add another layer of indirection here to allow mcc's to fill + * in Locales for TOS. TODO - REMOVE when needed locales supported + * natively (when not shipping devices to country X without support + * for their locale). + */ + String localeReplacement = context. + getString(com.android.internal.R.string.locale_replacement); + if (localeReplacement != null && localeReplacement.length() != 0) { + url = String.format(url, localeReplacement); + } + + Locale locale = Locale.getDefault(); + String tmp = locale.getLanguage() + "_" + locale.getCountry().toLowerCase(); + return String.format(url, tmp); + } + + private void initializeViews() { + + LayoutInflater inflater = + (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + mLayout = (ViewGroup) inflater.inflate( + com.android.internal.R.layout.google_web_content_helper_layout, null); + + mWebView = (WebView) mLayout.findViewById(com.android.internal.R.id.web); + mWebView.setWebViewClient(new MyWebViewClient()); + WebSettings settings = mWebView.getSettings(); + settings.setCacheMode(WebSettings.LOAD_NO_CACHE); + + mProgressBar = mLayout.findViewById(com.android.internal.R.id.progressContainer); + TextView message = (TextView) mProgressBar.findViewById(com.android.internal.R.id.message); + message.setText(com.android.internal.R.string.googlewebcontenthelper_loading); + + mTextView = (TextView) mLayout.findViewById(com.android.internal.R.id.text); + mTextView.setText(mUnsuccessfulMessage); + } + + private synchronized void handleWebViewCompletion(boolean success) { + + if (mReceivedResponse) { + return; + } else { + mReceivedResponse = true; + } + + // In both cases, remove the progress bar + ((ViewGroup) mProgressBar.getParent()).removeView(mProgressBar); + + // Remove the view that isn't relevant + View goneView = success ? mTextView : mWebView; + ((ViewGroup) goneView.getParent()).removeView(goneView); + + // Show the next view, which depends on success + View visibleView = success ? mWebView : mTextView; + visibleView.setVisibility(View.VISIBLE); + } + + private class MyWebViewClient extends WebViewClient { + + @Override + public void onPageFinished(WebView view, String url) { + handleWebViewCompletion(true); + } + + @Override + public void onReceivedError(WebView view, int errorCode, + String description, String failingUrl) { + handleWebViewCompletion(false); + } + + @Override + public void onReceivedHttpAuthRequest(WebView view, + HttpAuthHandler handler, String host, String realm) { + handleWebViewCompletion(false); + } + + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, + SslError error) { + handleWebViewCompletion(false); + } + + @Override + public void onTooManyRedirects(WebView view, Message cancelMsg, + Message continueMsg) { + handleWebViewCompletion(false); + } + + } + +} diff --git a/core/java/com/google/android/util/Procedure.java b/core/java/com/google/android/util/Procedure.java new file mode 100644 index 0000000..5ede2f0 --- /dev/null +++ b/core/java/com/google/android/util/Procedure.java @@ -0,0 +1,28 @@ +/* +** Copyright 2007, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +** See the License for the specific language governing permissions and +** limitations under the License. +*/ + +package com.google.android.util; + +/** + * A procedure. + */ +public interface Procedure<T> { + + /** + * Applies this procedure to the given parameter. + */ + void apply(T t); +} diff --git a/core/java/com/google/android/util/SimplePullParser.java b/core/java/com/google/android/util/SimplePullParser.java new file mode 100644 index 0000000..031790b --- /dev/null +++ b/core/java/com/google/android/util/SimplePullParser.java @@ -0,0 +1,391 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.util; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.Reader; +import java.io.Closeable; + +import android.util.Xml; +import android.util.Log; + +/** + * This is an abstraction of a pull parser that provides several benefits:<ul> + * <li>it is easier to use robustly because it makes it trivial to handle unexpected tags (which + * might have children)</li> + * <li>it makes the handling of text (cdata) blocks more convenient</li> + * <li>it provides convenient methods for getting a mandatory attribute (and throwing an exception + * if it is missing) or an optional attribute (and using a default value if it is missing) + * </ul> + */ +public class SimplePullParser { + public static final String TEXT_TAG = "![CDATA["; + + private String mLogTag = null; + private final XmlPullParser mParser; + private Closeable source; + private String mCurrentStartTag; + + /** + * Constructs a new SimplePullParser to parse the stream + * @param stream stream to parse + * @param encoding the encoding to use + */ + public SimplePullParser(InputStream stream, String encoding) + throws ParseException, IOException { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(stream, encoding); + moveToStartDocument(parser); + mParser = parser; + mCurrentStartTag = null; + source = stream; + } catch (XmlPullParserException e) { + throw new ParseException(e); + } + } + + /** + * Constructs a new SimplePullParser to parse the xml + * @param parser the underlying parser to use + */ + public SimplePullParser(XmlPullParser parser) { + mParser = parser; + mCurrentStartTag = null; + source = null; + } + + /** + * Constructs a new SimplePullParser to parse the xml + * @param xml the xml to parse + */ + public SimplePullParser(String xml) throws IOException, ParseException { + this(new StringReader(xml)); + } + + /** + * Constructs a new SimplePullParser to parse the xml + * @param reader a reader containing the xml + */ + public SimplePullParser(Reader reader) throws IOException, ParseException { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(reader); + moveToStartDocument(parser); + mParser = parser; + mCurrentStartTag = null; + source = reader; + } catch (XmlPullParserException e) { + throw new ParseException(e); + } + } + + private static void moveToStartDocument(XmlPullParser parser) + throws XmlPullParserException, IOException { + int eventType; + eventType = parser.getEventType(); + if (eventType != XmlPullParser.START_DOCUMENT) { + throw new XmlPullParserException("Not at start of response"); + } + } + + /** + * Enables logging to the provided log tag. A basic representation of the xml will be logged as + * the xml is parsed. No logging is done unless this is called. + * + * @param logTag the log tag to use when logging + */ + public void setLogTag(String logTag) { + mLogTag = logTag; + } + + /** + * Returns the tag of the next element whose depth is parentDepth plus one + * or null if there are no more such elements before the next start tag. When this returns, + * getDepth() and all methods relating to attributes will refer to the element whose tag is + * returned. + * + * @param parentDepth the depth of the parrent of the item to be returned + * @param textBuilder if null then text blocks will be ignored. If + * non-null then text blocks will be added to the builder and TEXT_TAG + * will be returned when one is found + * @return the next of the next child element's tag, TEXT_TAG if a text block is found, or null + * if there are no more child elements or DATA blocks + * @throws IOException propogated from the underlying parser + * @throws ParseException if there was an error parsing the xml. + */ + public String nextTagOrText(int parentDepth, StringBuilder textBuilder) + throws IOException, ParseException { + while (true) { + int eventType = 0; + try { + eventType = mParser.next(); + } catch (XmlPullParserException e) { + throw new ParseException(e); + } + int depth = mParser.getDepth(); + mCurrentStartTag = null; + + if (eventType == XmlPullParser.START_TAG && depth == parentDepth + 1) { + mCurrentStartTag = mParser.getName(); + if (mLogTag != null && Log.isLoggable(mLogTag, Log.DEBUG)) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < depth; i++) sb.append(" "); + sb.append("<").append(mParser.getName()); + int count = mParser.getAttributeCount(); + for (int i = 0; i < count; i++) { + sb.append(" "); + sb.append(mParser.getAttributeName(i)); + sb.append("=\""); + sb.append(mParser.getAttributeValue(i)); + sb.append("\""); + } + sb.append(">"); + Log.d(mLogTag, sb.toString()); + } + return mParser.getName(); + } + + if (eventType == XmlPullParser.END_TAG && depth == parentDepth) { + if (mLogTag != null && Log.isLoggable(mLogTag, Log.DEBUG)) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < depth; i++) sb.append(" "); + sb.append("</>"); // Not quite valid xml but it gets the job done. + Log.d(mLogTag, sb.toString()); + } + return null; + } + + if (eventType == XmlPullParser.END_DOCUMENT && parentDepth == 0) { + // we could just rely on the caller calling close(), which it should, but try + // to auto-close for clients that might have missed doing so. + if (source != null) { + source.close(); + source = null; + } + return null; + } + + if (eventType == XmlPullParser.TEXT && depth == parentDepth) { + if (textBuilder == null) { + continue; + } + String text = mParser.getText(); + textBuilder.append(text); + return TEXT_TAG; + } + } + } + + /** + * The same as nextTagOrTexxt(int, StringBuilder) but ignores text blocks. + */ + public String nextTag(int parentDepth) throws IOException, ParseException { + return nextTagOrText(parentDepth, null /* ignore text */); + } + + /** + * Returns the depth of the current element. The depth is 0 before the first + * element has been returned, 1 after that, etc. + * + * @return the depth of the current element + */ + public int getDepth() { + return mParser.getDepth(); + } + + /** + * Consumes the rest of the children, accumulating any text at this level into the builder. + * + * @param textBuilder the builder to contain any text + * @throws IOException propogated from the XmlPullParser + * @throws ParseException if there was an error parsing the xml. + */ + public void readRemainingText(int parentDepth, StringBuilder textBuilder) + throws IOException, ParseException { + while (nextTagOrText(parentDepth, textBuilder) != null) { + } + } + + /** + * Returns the number of attributes on the current element. + * + * @return the number of attributes on the current element + */ + public int numAttributes() { + return mParser.getAttributeCount(); + } + + /** + * Returns the name of the nth attribute on the current element. + * + * @return the name of the nth attribute on the current element + */ + public String getAttributeName(int i) { + return mParser.getAttributeName(i); + } + + /** + * Returns the namespace of the nth attribute on the current element. + * + * @return the namespace of the nth attribute on the current element + */ + public String getAttributeNamespace(int i) { + return mParser.getAttributeNamespace(i); + } + + /** + * Returns the string value of the named attribute. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute + * @param defaultValue the value to return if the attribute is not specified + * @return the value of the attribute + */ + public String getStringAttribute( + String namespace, String name, String defaultValue) { + String value = mParser.getAttributeValue(namespace, name); + if (null == value) return defaultValue; + return value; + } + + /** + * Returns the string value of the named attribute. An exception will + * be thrown if the attribute is not present. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute @return the value of the attribute + * @throws ParseException thrown if the attribute is missing + */ + public String getStringAttribute(String namespace, String name) throws ParseException { + String value = mParser.getAttributeValue(namespace, name); + if (null == value) { + throw new ParseException( + "missing '" + name + "' attribute on '" + mCurrentStartTag + "' element"); + } + return value; + } + + /** + * Returns the string value of the named attribute. An exception will + * be thrown if the attribute is not a valid integer. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute + * @param defaultValue the value to return if the attribute is not specified + * @return the value of the attribute + * @throws ParseException thrown if the attribute not a valid integer. + */ + public int getIntAttribute(String namespace, String name, int defaultValue) + throws ParseException { + String value = mParser.getAttributeValue(namespace, name); + if (null == value) return defaultValue; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new ParseException("Cannot parse '" + value + "' as an integer"); + } + } + + /** + * Returns the string value of the named attribute. An exception will + * be thrown if the attribute is not present or is not a valid integer. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute @return the value of the attribute + * @throws ParseException thrown if the attribute is missing or not a valid integer. + */ + public int getIntAttribute(String namespace, String name) + throws ParseException { + String value = getStringAttribute(namespace, name); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new ParseException("Cannot parse '" + value + "' as an integer"); + } + } + + /** + * Returns the string value of the named attribute. An exception will + * be thrown if the attribute is not a valid long. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute @return the value of the attribute + * @throws ParseException thrown if the attribute is not a valid long. + */ + public long getLongAttribute(String namespace, String name, long defaultValue) + throws ParseException { + String value = mParser.getAttributeValue(namespace, name); + if (null == value) return defaultValue; + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new ParseException("Cannot parse '" + value + "' as a long"); + } + } + + /** + * Close this SimplePullParser and any underlying resources (e.g., its InputStream or + * Reader source) used by this SimplePullParser. + */ + public void close() { + if (source != null) { + try { + source.close(); + } catch (IOException ioe) { + // ignore + } + } + } + + /** + * Returns the string value of the named attribute. An exception will + * be thrown if the attribute is not present or is not a valid long. + * + * @param namespace the namespace of the attribute + * @param name the name of the attribute @return the value of the attribute + * @throws ParseException thrown if the attribute is missing or not a valid long. + */ + public long getLongAttribute(String namespace, String name) + throws ParseException { + String value = getStringAttribute(namespace, name); + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new ParseException("Cannot parse '" + value + "' as a long"); + } + } + + public static final class ParseException extends Exception { + public ParseException(String message) { + super(message); + } + + public ParseException(String message, Throwable cause) { + super(message, cause); + } + + public ParseException(Throwable cause) { + super(cause); + } + } +} diff --git a/core/java/com/google/android/util/SmileyParser.java b/core/java/com/google/android/util/SmileyParser.java new file mode 100644 index 0000000..ef5d2a9 --- /dev/null +++ b/core/java/com/google/android/util/SmileyParser.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.util; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.ImageSpan; + +import java.util.ArrayList; + +/** + * Parses a text message typed by the user looking for smileys. + */ +public class SmileyParser extends AbstractMessageParser { + + private SmileyResources mRes; + + public SmileyParser(String text, SmileyResources res) { + super(text, + true, // smilies + false, // acronyms + false, // formatting + false, // urls + false, // music + false // me text + ); + mRes = res; + } + + @Override + protected Resources getResources() { + return mRes; + } + + /** + * Retrieves the parsed text as a spannable string object. + * @param context the context for fetching smiley resources. + * @return the spannable string as CharSequence. + */ + public CharSequence getSpannableString(Context context) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + + if (getPartCount() == 0) { + return ""; + } + + // should have only one part since we parse smiley only + Part part = getPart(0); + ArrayList<Token> tokens = part.getTokens(); + int len = tokens.size(); + for (int i = 0; i < len; i++) { + Token token = tokens.get(i); + int start = builder.length(); + builder.append(token.getRawText()); + if (token.getType() == AbstractMessageParser.Token.Type.SMILEY) { + int resid = mRes.getSmileyRes(token.getRawText()); + if (resid != -1) { + builder.setSpan(new ImageSpan(context, resid), + start, + builder.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + return builder; + } + +} diff --git a/core/java/com/google/android/util/SmileyResources.java b/core/java/com/google/android/util/SmileyResources.java new file mode 100644 index 0000000..789158f --- /dev/null +++ b/core/java/com/google/android/util/SmileyResources.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.util; + +import com.google.android.util.AbstractMessageParser.TrieNode; + +import java.util.HashMap; +import java.util.Set; + +/** + * Resources for smiley parser. + */ +public class SmileyResources implements AbstractMessageParser.Resources { + private HashMap<String, Integer> mSmileyToRes = new HashMap<String, Integer>(); + + /** + * + * @param smilies Smiley text, e.g. ":)", "8-)" + * @param smileyResIds Resource IDs associated with the smileys. + */ + public SmileyResources(String[] smilies, int[] smileyResIds) { + for (int i = 0; i < smilies.length; i++) { + TrieNode.addToTrie(smileys, smilies[i], ""); + mSmileyToRes.put(smilies[i], smileyResIds[i]); + } + } + + /** + * Looks up the resource id of a given smiley. + * @param smiley The smiley to look up. + * @return the resource id of the specified smiley, or -1 if no resource + * id is associated with it. + */ + public int getSmileyRes(String smiley) { + Integer i = mSmileyToRes.get(smiley); + if (i == null) { + return -1; + } + return i.intValue(); + } + + private final TrieNode smileys = new TrieNode(); + + public Set<String> getSchemes() { + return null; + } + + public TrieNode getDomainSuffixes() { + return null; + } + + public TrieNode getSmileys() { + return smileys; + } + + public TrieNode getAcronyms() { + return null; + } + +} |