diff --git a/facebook/.gitignore b/facebook/.gitignore new file mode 100644 index 000000000..47acd648f --- /dev/null +++ b/facebook/.gitignore @@ -0,0 +1,15 @@ +*~ +*.DS_Store +*.class +*java# +*.classpath +*.project +*.settings +facebook/bin/ +facebook/gen/ +examples/stream/bin/ +examples/simple/bin/ +examples/stream/gen/ +examples/simple/gen/ +tests/bin +tests/gen \ No newline at end of file diff --git a/facebook/Facebook.apk b/facebook/Facebook.apk new file mode 100644 index 000000000..599955524 Binary files /dev/null and b/facebook/Facebook.apk differ diff --git a/facebook/README.md b/facebook/README.md new file mode 100644 index 000000000..8b06c9049 --- /dev/null +++ b/facebook/README.md @@ -0,0 +1,70 @@ +This open source Java library allows you to integrate Facebook into your Android application. Except as otherwise noted, the Facebook Android SDK is licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html) + +Getting Started +=============== + +See our [Android SDK Getting Started Guide](http://developers.facebook.com/docs/guides/mobile#android) + +Sample Applications +=============== + +This library includes two sample applications to guide you in development. + +* __simple__: A bare-bones app that demonstrates authorization, making API calls, and invoking a dialog. + +* __stream__: This slightly beefier application lets you view your news feed. + +To install a sample application into Eclipse (3.5): + +* Create the sample application in your workspace: +2. Select __File__ -> __New__ -> __Project__, choose __Android Project__, and then click __Next__. + 3. Select "Create project from existing source". + 4. Choose either __examples/simple__ or __examples/stream__. You should see the project properties populated. + 5. Click Finish to continue. + +* Build the project: from the Project menu, select "Build Project". + +* Run the application: from the Run menu, select "Run Configurations...". Under Android Application, you can create a new run configuration: give it a name and select the simple Example project; use the default activity Launch Action. See http://developer.android.com/guide/developing/eclipse-adt.html#RunConfig for more details. + + +Testing +=============== + +Here are some tips to help test your application: + +* You will need to have the Facebook application in your test environment. The SDK includes a developer release of the Facebook application that can be side-loaded for testing purposes. On an actual device, you can just download the latest version of the app from the Android Market, but on the emulator you will have to install it yourself: + + adb install FBAndroid.apk + +* Use a signed build. You can sign with a debug key, but make sure that the key you used to sign matches the __Key Hash__ field in the Facebook developer settings. + +* Make sure to test both with and without the Facebook application. The SDK will fall back to a Webview if the Facebook app is not installed. + +* You can use this [guide to developing on a device](http://developer.android.com/guide/developing/device.html). + +Debugging +========== + +Here's a few common errors and their solutions. + +* __Build error: "missing gen files".__ + + This should go away when you rebuild the project. If you have trouble, try running __Clean...__ from the __Project__ menu. + +* __Error: "invalid_key"__ + + This error means that the Facebook server doesn't recognize your Android key hash. Make sure that you correctly generated and copy/pasted your key hash into the Facebook developer settings console (http://www.facebook.com/developers/apps.php), and make sure that your application has been signed with the same key you registered with Facebook. + +* __Dialog won't load or shows a blank screen.__ + + This can be tricky to debug. If the logs don't give an indication of what's wrong, I suggest installing tcpdump on your device and getting a trace. Tutorial: http://www.vbsteven.be/blog/android-debugging-inspectin-network-traffic-with-tcpdump/ + + If you still can't tell what's going on, then file an issue and please include the HTTP trace. + +* __I can't upload photos with photos.upload.__ + + Make sure the Bundle value for the photo parameter is a byte array. + +Report Issues/Bugs +=============== +[http://bugs.developers.facebook.net/enter_bug.cgi?product=SDKs](http://bugs.developers.facebook.net/enter_bug.cgi?product=SDKs) diff --git a/facebook/examples/simple/AndroidManifest.xml b/facebook/examples/simple/AndroidManifest.xml new file mode 100644 index 000000000..f647de1aa --- /dev/null +++ b/facebook/examples/simple/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/facebook/examples/simple/default.properties b/facebook/examples/simple/default.properties new file mode 100644 index 000000000..0f4dfe443 --- /dev/null +++ b/facebook/examples/simple/default.properties @@ -0,0 +1,14 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system use, +# "build.properties", and override values to adapt the script to your +# project structure. + +# Indicates whether an apk should be generated for each density. +split.density=false +android.library.reference.1=../../facebook/ +# Project target. +target=android-3 diff --git a/facebook/examples/simple/res/drawable/icon.png b/facebook/examples/simple/res/drawable/icon.png new file mode 100644 index 000000000..a07c69fa5 Binary files /dev/null and b/facebook/examples/simple/res/drawable/icon.png differ diff --git a/facebook/examples/simple/res/drawable/login.png b/facebook/examples/simple/res/drawable/login.png new file mode 100644 index 000000000..95c8dc156 Binary files /dev/null and b/facebook/examples/simple/res/drawable/login.png differ diff --git a/facebook/examples/simple/res/drawable/login_button.xml b/facebook/examples/simple/res/drawable/login_button.xml new file mode 100644 index 000000000..73dc6c3ab --- /dev/null +++ b/facebook/examples/simple/res/drawable/login_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/facebook/examples/simple/res/drawable/login_down.png b/facebook/examples/simple/res/drawable/login_down.png new file mode 100644 index 000000000..10d216b3f Binary files /dev/null and b/facebook/examples/simple/res/drawable/login_down.png differ diff --git a/facebook/examples/simple/res/drawable/logout.png b/facebook/examples/simple/res/drawable/logout.png new file mode 100644 index 000000000..290272aa0 Binary files /dev/null and b/facebook/examples/simple/res/drawable/logout.png differ diff --git a/facebook/examples/simple/res/drawable/logout_button.xml b/facebook/examples/simple/res/drawable/logout_button.xml new file mode 100644 index 000000000..58f57656b --- /dev/null +++ b/facebook/examples/simple/res/drawable/logout_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/facebook/examples/simple/res/drawable/logout_down.png b/facebook/examples/simple/res/drawable/logout_down.png new file mode 100644 index 000000000..1ab14b9dc Binary files /dev/null and b/facebook/examples/simple/res/drawable/logout_down.png differ diff --git a/facebook/examples/simple/res/layout/main.xml b/facebook/examples/simple/res/layout/main.xml new file mode 100644 index 000000000..b73286a71 --- /dev/null +++ b/facebook/examples/simple/res/layout/main.xml @@ -0,0 +1,65 @@ + + + + + + + + ", + "
", + "" + }; + append(chunks); + } + + /** + * Renders a single post + * + * @param post + * @throws JSONException + */ + private void renderPost(JSONObject post) throws JSONException { + append("
"); + renderFrom(post); + renderTo(post); + renderMessage(post); + renderAttachment(post); + renderActionLinks(post); + renderLikes(post); + renderComments(post); + renderCommentBox(post); + append("
"); + } + + + /** + * Renders the author's name + * + * @param post + * @throws JSONException + */ + private void renderFrom(JSONObject post) throws JSONException { + JSONObject from = post.getJSONObject("from"); + String fromName = from.getString("name"); + String fromId = from.getString("id"); + renderAuthor(fromId, fromName); + } + + /** + * If it's a wall post on a friend's fall, renders + * the recipient's name preceded by a '>'. + * + * @param post + * @throws JSONException + */ + private void renderTo(JSONObject post) throws JSONException { + JSONObject to = post.optJSONObject("to"); + if (to != null) { + JSONObject toData = to.getJSONArray("data").getJSONObject(0); + String toName = toData.getString("name"); + String toId = toData.getString("id"); + append(" > "); + renderProfileLink(toId, toName); + } + } + + /** + * Renders a link to a user. + * + * @param id + * @param name + */ + private void renderProfileLink(String id, String name) { + renderLink(getProfileUrl(id), name); + } + + private String getProfileUrl(String id) { + return "http://touch.facebook.com/#/profile.php?id=" + id; + } + + /** + * Renders the author pic and name. + * + * @param id + * @param name + */ + private void renderAuthor(String id, String name) { + String[] chunks = { + "
", + "", + "
" + }; + append(chunks); + renderProfileLink(id, name); + } + + /** + * Renders the post message. + * + * @param post + */ + private void renderMessage(JSONObject post) { + String message = post.optString("message"); + String[] chunks = { + " ", message, "", + "
" + }; + append(chunks); + } + + /** + * Renders the attachment. + * + * @param post + */ + private void renderAttachment(JSONObject post) { + String name = post.optString("name"); + String link = post.optString("link"); + String picture = post.optString("picture"); + String source = post.optString("source"); // for videos + String caption = post.optString("caption"); + String description = post.optString("description"); + + String[] fields = new String[] { + name, link, picture, source, caption, description + }; + boolean hasAttachment = false; + for (String field : fields) { + if (field.length() != 0) { + hasAttachment = true; + break; + } + } + + if (!hasAttachment) { + return; + } + + append("
"); + if (name != "") { + append("
"); + if (link != null) { + renderLink(link, name); + } else { + append(name); + } + append("
"); + } + if (caption != "") { + append("
" + caption + "
"); + } + + if (picture != "") { + append("
"); + String img = ""; + if (link != "") { + renderLink(link, img); + } else { + append(img); + } + append("
"); + } + + if (description != "") { + append("
" + description + "
"); + } + append("
"); + } + + /** + * Renders an anchor tag + * + * @param href + * @param text + */ + private void renderLink(String href, String text) { + append(new String[] { + "", + text, + "" + }); + } + + /** + * Renders the posts' action links. + * + * @param post + */ + private void renderActionLinks(JSONObject post) { + HashSet actions = getActions(post); + append("
"); + append("
"); + renderTimeStamp(post); + append("
"); + String post_id = post.optString("id"); + if (actions.contains("Comment")) { + renderActionLink(post_id, "Comment", "comment"); + } + boolean canLike = actions.contains("Like"); + renderActionLink(post_id, "Like", "like", canLike); + renderActionLink(post_id, "Unlike", "unlike", !canLike); + + append("
"); + } + + /** + * Renders a single visible action link. + * + * @param post_id + * @param title + * @param func + */ + private void renderActionLink(String post_id, String title, String func) { + renderActionLink(post_id, title, func, true); + } + + /** + * Renders an action link with optional visibility. + * + * @param post_id + * @param title + * @param func + * @param visible + */ + private void renderActionLink(String post_id, String title, String func, + boolean visible) { + String extraClass = visible ? "" : "hidden"; + String[] chunks = new String[] { + "" + }; + append(chunks); + } + + /** + * Renders the post's timestamp. + * + * @param post + */ + private void renderTimeStamp(JSONObject post) { + String dateStr = post.optString("created_time"); + SimpleDateFormat formatter = getDateFormat(); + ParsePosition pos = new ParsePosition(0); + long then = formatter.parse(dateStr, pos).getTime(); + long now = new Date().getTime(); + + long seconds = (now - then)/1000; + long minutes = seconds/60; + long hours = minutes/60; + long days = hours/24; + + String friendly = null; + long num = 0; + if (days > 0) { + num = days; + friendly = days + " day"; + } else if (hours > 0) { + num = hours; + friendly = hours + " hour"; + } else if (minutes > 0) { + num = minutes; + friendly = minutes + " minute"; + } else { + num = seconds; + friendly = seconds + " second"; + } + if (num > 1) { + friendly += "s"; + } + String[] chunks = new String[] { + "
", + friendly, + " ago", + "
" + }; + append(chunks); + } + + /** + * Returns the available actions for the post. + * + * @param post + * @return + */ + private HashSet getActions(JSONObject post) { + HashSet actionsSet = new HashSet(); + JSONArray actions = post.optJSONArray("actions"); + if (actions != null) { + for (int j = 0; j < actions.length(); j++) { + JSONObject action = actions.optJSONObject(j); + String actionName = action.optString("name"); + actionsSet.add(actionName); + } + } + return actionsSet; + } + + /** + * Renders the 'x people like this' text, + * + * @param post + */ + private void renderLikes(JSONObject post) { + int numLikes = post.optInt("likes", 0); + if (numLikes > 0) { + String desc = numLikes == 1 ? + "person likes this" : + "people like this"; + String[] chunks = new String[] { + "
", + "", + "
", + "
", + new Integer(numLikes).toString(), + " ", + desc, + "
" + }; + append(chunks); + } + } + + /** + * Renders the post's comments. + * + * @param post + * @throws JSONException + */ + private void renderComments(JSONObject post) throws JSONException { + append("
"); + JSONObject comments = post.optJSONObject("comments"); + if (comments != null) { + JSONArray data = comments.optJSONArray("data"); + if (data != null) { + for (int j = 0; j < data.length(); j++) { + JSONObject comment = data.getJSONObject(j); + renderComment(comment); + } + } + } + append("
"); + } + + /** + * Renders an individual comment. + * + * @param comment + */ + private void renderComment(JSONObject comment) { + JSONObject from = comment.optJSONObject("from"); + if (from == null) { + Log.w("StreamRenderer", + "Comment missing from field: " + comment.toString()); + } else { + String authorId = from.optString("id"); + String authorName = from.optString("name"); + renderAuthor(authorId, authorName); + } + String message = comment.optString("message"); + append("
"); + String[] chunks = { + " ", + message, + "
" + }; + append(chunks); + } + + + /** + * Renders the new comment input box. + * + * @param post + */ + private void renderCommentBox(JSONObject post) { + String id = post.optString("id"); + String[] chunks = new String[] { + "
", + "", + "", + "
", + "
" + }; + append(chunks); + } + + + private void append(String str) { + sb.append(str); + } + + private void append(String[] chunks) { + for (String chunk : chunks) { + sb.append(chunk); + } + } +} \ No newline at end of file diff --git a/facebook/facebook/AndroidManifest.xml b/facebook/facebook/AndroidManifest.xml new file mode 100644 index 000000000..e6940f838 --- /dev/null +++ b/facebook/facebook/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/facebook/facebook/default.properties b/facebook/facebook/default.properties new file mode 100644 index 000000000..2262a388e --- /dev/null +++ b/facebook/facebook/default.properties @@ -0,0 +1,12 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system use, +# "build.properties", and override values to adapt the script to your +# project structure. + +android.library=true +# Project target. +target=android-3 diff --git a/facebook/facebook/res/drawable-hdpi/facebook_icon.png b/facebook/facebook/res/drawable-hdpi/facebook_icon.png new file mode 100644 index 000000000..af8e077ac Binary files /dev/null and b/facebook/facebook/res/drawable-hdpi/facebook_icon.png differ diff --git a/facebook/facebook/res/drawable-ldpi/facebook_icon.png b/facebook/facebook/res/drawable-ldpi/facebook_icon.png new file mode 100644 index 000000000..5bbc2cc91 Binary files /dev/null and b/facebook/facebook/res/drawable-ldpi/facebook_icon.png differ diff --git a/facebook/facebook/res/drawable/facebook_icon.png b/facebook/facebook/res/drawable/facebook_icon.png new file mode 100644 index 000000000..413396be6 Binary files /dev/null and b/facebook/facebook/res/drawable/facebook_icon.png differ diff --git a/facebook/facebook/src/com/facebook/android/AsyncFacebookRunner.java b/facebook/facebook/src/com/facebook/android/AsyncFacebookRunner.java new file mode 100644 index 000000000..be3870a1a --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/AsyncFacebookRunner.java @@ -0,0 +1,316 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.MalformedURLException; + +import android.content.Context; +import android.os.Bundle; + +/** + * A sample implementation of asynchronous API requests. This class provides + * the ability to execute API methods and have the call return immediately, + * without blocking the calling thread. This is necessary when accessing the + * API in the UI thread, for instance. The request response is returned to + * the caller via a callback interface, which the developer must implement. + * + * This sample implementation simply spawns a new thread for each request, + * and makes the API call immediately. This may work in many applications, + * but more sophisticated users may re-implement this behavior using a thread + * pool, a network thread, a request queue, or other mechanism. Advanced + * functionality could be built, such as rate-limiting of requests, as per + * a specific application's needs. + * + * @see RequestListener + * The callback interface. + * + * @author Jim Brusstar (jimbru@fb.com), + * Yariv Sadan (yariv@fb.com), + * Luke Shepard (lshepard@fb.com) + */ +public class AsyncFacebookRunner { + + Facebook fb; + + public AsyncFacebookRunner(Facebook fb) { + this.fb = fb; + } + + /** + * Invalidate the current user session by removing the access token in + * memory, clearing the browser cookies, and calling auth.expireSession + * through the API. The application will be notified when logout is + * complete via the callback interface. + * + * Note that this method is asynchronous and the callback will be invoked + * in a background thread; operations that affect the UI will need to be + * posted to the UI thread or an appropriate handler. + * + * @param context + * The Android context in which the logout should be called: it + * should be the same context in which the login occurred in + * order to clear any stored cookies + * @param listener + * Callback interface to notify the application when the request + * has completed. + * @param state + * An arbitrary object used to identify the request when it + * returns to the callback. This has no effect on the request + * itself. + */ + public void logout(final Context context, + final RequestListener listener, + final Object state) { + new Thread() { + @Override public void run() { + try { + String response = fb.logout(context); + if (response.length() == 0 || response.equals("false")){ + listener.onFacebookError(new FacebookError( + "auth.expireSession failed"), state); + return; + } + listener.onComplete(response, state); + } catch (FileNotFoundException e) { + listener.onFileNotFoundException(e, state); + } catch (MalformedURLException e) { + listener.onMalformedURLException(e, state); + } catch (IOException e) { + listener.onIOException(e, state); + } + } + }.start(); + } + + public void logout(final Context context, final RequestListener listener) { + logout(context, listener, /* state */ null); + } + + /** + * Make a request to Facebook's old (pre-graph) API with the given + * parameters. One of the parameter keys must be "method" and its value + * should be a valid REST server API method. + * + * See http://developers.facebook.com/docs/reference/rest/ + * + * Note that this method is asynchronous and the callback will be invoked + * in a background thread; operations that affect the UI will need to be + * posted to the UI thread or an appropriate handler. + * + * Example: + * + * Bundle parameters = new Bundle(); + * parameters.putString("method", "auth.expireSession", new Listener()); + * String response = request(parameters); + * + * + * @param parameters + * Key-value pairs of parameters to the request. Refer to the + * documentation: one of the parameters must be "method". + * @param listener + * Callback interface to notify the application when the request + * has completed. + * @param state + * An arbitrary object used to identify the request when it + * returns to the callback. This has no effect on the request + * itself. + */ + public void request(Bundle parameters, + RequestListener listener, + final Object state) { + request(null, parameters, "GET", listener, state); + } + + public void request(Bundle parameters, RequestListener listener) { + request(null, parameters, "GET", listener, /* state */ null); + } + + /** + * Make a request to the Facebook Graph API without any parameters. + * + * See http://developers.facebook.com/docs/api + * + * Note that this method is asynchronous and the callback will be invoked + * in a background thread; operations that affect the UI will need to be + * posted to the UI thread or an appropriate handler. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @param listener + * Callback interface to notify the application when the request + * has completed. + * @param state + * An arbitrary object used to identify the request when it + * returns to the callback. This has no effect on the request + * itself. + */ + public void request(String graphPath, + RequestListener listener, + final Object state) { + request(graphPath, new Bundle(), "GET", listener, state); + } + + public void request(String graphPath, RequestListener listener) { + request(graphPath, new Bundle(), "GET", listener, /* state */ null); + } + + /** + * Make a request to the Facebook Graph API with the given string parameters + * using an HTTP GET (default method). + * + * See http://developers.facebook.com/docs/api + * + * Note that this method is asynchronous and the callback will be invoked + * in a background thread; operations that affect the UI will need to be + * posted to the UI thread or an appropriate handler. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @param parameters + * key-value string parameters, e.g. the path "search" with + * parameters "q" : "facebook" would produce a query for the + * following graph resource: + * https://graph.facebook.com/search?q=facebook + * @param listener + * Callback interface to notify the application when the request + * has completed. + * @param state + * An arbitrary object used to identify the request when it + * returns to the callback. This has no effect on the request + * itself. + */ + public void request(String graphPath, + Bundle parameters, + RequestListener listener, + final Object state) { + request(graphPath, parameters, "GET", listener, state); + } + + public void request(String graphPath, + Bundle parameters, + RequestListener listener) { + request(graphPath, parameters, "GET", listener, /* state */ null); + } + + /** + * Make a request to the Facebook Graph API with the given HTTP method and + * string parameters. Note that binary data parameters (e.g. pictures) are + * not yet supported by this helper function. + * + * See http://developers.facebook.com/docs/api + * + * Note that this method is asynchronous and the callback will be invoked + * in a background thread; operations that affect the UI will need to be + * posted to the UI thread or an appropriate handler. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @param parameters + * key-value string parameters, e.g. the path "search" with + * parameters {"q" : "facebook"} would produce a query for the + * following graph resource: + * https://graph.facebook.com/search?q=facebook + * @param httpMethod + * http verb, e.g. "POST", "DELETE" + * @param listener + * Callback interface to notify the application when the request + * has completed. + * @param state + * An arbitrary object used to identify the request when it + * returns to the callback. This has no effect on the request + * itself. + */ + public void request(final String graphPath, + final Bundle parameters, + final String httpMethod, + final RequestListener listener, + final Object state) { + new Thread() { + @Override public void run() { + try { + String resp = fb.request(graphPath, parameters, httpMethod); + listener.onComplete(resp, state); + } catch (FileNotFoundException e) { + listener.onFileNotFoundException(e, state); + } catch (MalformedURLException e) { + listener.onMalformedURLException(e, state); + } catch (IOException e) { + listener.onIOException(e, state); + } + } + }.start(); + } + + /** + * Callback interface for API requests. + * + * Each method includes a 'state' parameter that identifies the calling + * request. It will be set to the value passed when originally calling the + * request method, or null if none was passed. + */ + public static interface RequestListener { + + /** + * Called when a request completes with the given response. + * + * Executed by a background thread: do not update the UI in this method. + */ + public void onComplete(String response, Object state); + + /** + * Called when a request has a network or request error. + * + * Executed by a background thread: do not update the UI in this method. + */ + public void onIOException(IOException e, Object state); + + /** + * Called when a request fails because the requested resource is + * invalid or does not exist. + * + * Executed by a background thread: do not update the UI in this method. + */ + public void onFileNotFoundException(FileNotFoundException e, + Object state); + + /** + * Called if an invalid graph path is provided (which may result in a + * malformed URL). + * + * Executed by a background thread: do not update the UI in this method. + */ + public void onMalformedURLException(MalformedURLException e, + Object state); + + /** + * Called when the server-side Facebook method fails. + * + * Executed by a background thread: do not update the UI in this method. + */ + public void onFacebookError(FacebookError e, Object state); + + } + +} diff --git a/facebook/facebook/src/com/facebook/android/DialogError.java b/facebook/facebook/src/com/facebook/android/DialogError.java new file mode 100644 index 000000000..51d06c9a1 --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/DialogError.java @@ -0,0 +1,51 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +/** + * Encapsulation of Dialog Error. + * + * @author ssoneff@facebook.com + */ +public class DialogError extends Throwable { + + private static final long serialVersionUID = 1L; + + /** + * The ErrorCode received by the WebView: see + * http://developer.android.com/reference/android/webkit/WebViewClient.html + */ + private int mErrorCode; + + /** The URL that the dialog was trying to load */ + private String mFailingUrl; + + public DialogError(String message, int errorCode, String failingUrl) { + super(message); + mErrorCode = errorCode; + mFailingUrl = failingUrl; + } + + int getErrorCode() { + return mErrorCode; + } + + String getFailingUrl() { + return mFailingUrl; + } + +} diff --git a/facebook/facebook/src/com/facebook/android/Facebook.java b/facebook/facebook/src/com/facebook/android/Facebook.java new file mode 100644 index 000000000..f4f02a549 --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/Facebook.java @@ -0,0 +1,761 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.MalformedURLException; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.pm.Signature; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.CookieSyncManager; + +/** + * Main Facebook object for interacting with the Facebook developer API. + * Provides methods to log in and log out a user, make requests using the REST + * and Graph APIs, and start user interface interactions with the API (such as + * pop-ups promoting for credentials, permissions, stream posts, etc.) + * + * @author Jim Brusstar (jimbru@facebook.com), + * Yariv Sadan (yariv@facebook.com), + * Luke Shepard (lshepard@facebook.com) + */ +public class Facebook { + + // Strings used in the authorization flow + public static final String REDIRECT_URI = "fbconnect://success"; + public static final String CANCEL_URI = "fbconnect://cancel"; + public static final String TOKEN = "access_token"; + public static final String EXPIRES = "expires_in"; + public static final String SINGLE_SIGN_ON_DISABLED = "service_disabled"; + + public static final int FORCE_DIALOG_AUTH = -1; + + private static final String LOGIN = "oauth"; + + // Used as default activityCode by authorize(). See authorize() below. + private static final int DEFAULT_AUTH_ACTIVITY_CODE = 32665; + + // Facebook server endpoints: may be modified in a subclass for testing + protected static String DIALOG_BASE_URL = + "https://m.facebook.com/dialog/"; + protected static String GRAPH_BASE_URL = + "https://graph.facebook.com/"; + protected static String RESTSERVER_URL = + "https://api.facebook.com/restserver.php"; + + private String mAccessToken = null; + private long mAccessExpires = 0; + private String mAppId; + + private Activity mAuthActivity; + private String[] mAuthPermissions; + private int mAuthActivityCode; + private DialogListener mAuthDialogListener; + + /** + * Constructor for Facebook object. + * + * @param appId + * Your Facebook application ID. Found at + * www.facebook.com/developers/apps.php. + */ + public Facebook(String appId) { + if (appId == null) { + throw new IllegalArgumentException( + "You must specify your application ID when instantiating " + + "a Facebook object. See README for details."); + } + mAppId = appId; + } + + /** + * Default authorize method. Grants only basic permissions. + * + * See authorize() below for @params. + */ + public void authorize(Activity activity, final DialogListener listener) { + authorize(activity, new String[] {}, DEFAULT_AUTH_ACTIVITY_CODE, + listener); + } + + /** + * Authorize method that grants custom permissions. + * + * See authorize() below for @params. + */ + public void authorize(Activity activity, String[] permissions, + final DialogListener listener) { + authorize(activity, permissions, DEFAULT_AUTH_ACTIVITY_CODE, listener); + } + + /** + * Full authorize method. + * + * Starts either an Activity or a dialog which prompts the user to log in to + * Facebook and grant the requested permissions to the given application. + * + * This method will, when possible, use Facebook's single sign-on for + * Android to obtain an access token. This involves proxying a call through + * the Facebook for Android stand-alone application, which will handle the + * authentication flow, and return an OAuth access token for making API + * calls. + * + * Because this process will not be available for all users, if single + * sign-on is not possible, this method will automatically fall back to the + * OAuth 2.0 User-Agent flow. In this flow, the user credentials are handled + * by Facebook in an embedded WebView, not by the client application. As + * such, the dialog makes a network request and renders HTML content rather + * than a native UI. The access token is retrieved from a redirect to a + * special URL that the WebView handles. + * + * Note that User credentials could be handled natively using the OAuth 2.0 + * Username and Password Flow, but this is not supported by this SDK. + * + * See http://developers.facebook.com/docs/authentication/ and + * http://wiki.oauth.net/OAuth-2 for more details. + * + * Note that this method is asynchronous and the callback will be invoked in + * the original calling thread (not in a background thread). + * + * Also note that requests may be made to the API without calling authorize + * first, in which case only public information is returned. + * + * IMPORTANT: Note that single sign-on authentication will not function + * correctly if you do not include a call to the authorizeCallback() method + * in your onActivityResult() function! Please see below for more + * information. single sign-on may be disabled by passing FORCE_DIALOG_AUTH + * as the activityCode parameter in your call to authorize(). + * + * @param activity + * The Android activity in which we want to display the + * authorization dialog. + * @param applicationId + * The Facebook application identifier e.g. "350685531728" + * @param permissions + * A list of permissions required for this application: e.g. + * "read_stream", "publish_stream", "offline_access", etc. see + * http://developers.facebook.com/docs/authentication/permissions + * This parameter should not be null -- if you do not require any + * permissions, then pass in an empty String array. + * @param activityCode + * Single sign-on requires an activity result to be called back + * to the client application -- if you are waiting on other + * activities to return data, pass a custom activity code here to + * avoid collisions. If you would like to force the use of legacy + * dialog-based authorization, pass FORCE_DIALOG_AUTH for this + * parameter. Otherwise just omit this parameter and Facebook + * will use a suitable default. See + * http://developer.android.com/reference/android/ + * app/Activity.html for more information. + * @param listener + * Callback interface for notifying the calling application when + * the authentication dialog has completed, failed, or been + * canceled. + */ + public void authorize(Activity activity, String[] permissions, + int activityCode, final DialogListener listener) { + + boolean singleSignOnStarted = false; + + mAuthDialogListener = listener; + + // Prefer single sign-on, where available. + if (activityCode >= 0) { + singleSignOnStarted = startSingleSignOn(activity, mAppId, + permissions, activityCode); + } + // Otherwise fall back to traditional dialog. + if (!singleSignOnStarted) { + startDialogAuth(activity, permissions); + } + } + + /** + * Internal method to handle single sign-on backend for authorize(). + * + * @param activity + * The Android Activity that will parent the ProxyAuth Activity. + * @param applicationId + * The Facebook application identifier. + * @param permissions + * A list of permissions required for this application. If you do + * not require any permissions, pass an empty String array. + * @param activityCode + * Activity code to uniquely identify the result Intent in the + * callback. + */ + private boolean startSingleSignOn(Activity activity, String applicationId, + String[] permissions, int activityCode) { + boolean didSucceed = true; + Intent intent = new Intent(); + + intent.setClassName("com.facebook.katana", + "com.facebook.katana.ProxyAuth"); + intent.putExtra("client_id", applicationId); + if (permissions.length > 0) { + intent.putExtra("scope", TextUtils.join(",", permissions)); + } + + // Verify that the application whose package name is + // com.facebook.katana.ProxyAuth + // has the expected FB app signature. + if (!validateAppSignatureForIntent(activity, intent)) { + return false; + } + + mAuthActivity = activity; + mAuthPermissions = permissions; + mAuthActivityCode = activityCode; + try { + activity.startActivityForResult(intent, activityCode); + } catch (ActivityNotFoundException e) { + didSucceed = false; + } + + return didSucceed; + } + + /** + * Query the signature for the application that would be invoked by the + * given intent and verify that it matches the FB application's signature. + * + * @param activity + * @param intent + * @param validSignature + * @return true if the app's signature matches the expected signature. + */ + private boolean validateAppSignatureForIntent(Activity activity, + Intent intent) { + + ResolveInfo resolveInfo = + activity.getPackageManager().resolveActivity(intent, 0); + if (resolveInfo == null) { + return false; + } + + String packageName = resolveInfo.activityInfo.packageName; + PackageInfo packageInfo; + try { + packageInfo = activity.getPackageManager().getPackageInfo( + packageName, PackageManager.GET_SIGNATURES); + } catch (NameNotFoundException e) { + return false; + } + + for (Signature signature : packageInfo.signatures) { + if (signature.toCharsString().equals(FB_APP_SIGNATURE)) { + return true; + } + } + return false; + } + + /** + * Internal method to handle dialog-based authentication backend for + * authorize(). + * + * @param activity + * The Android Activity that will parent the auth dialog. + * @param applicationId + * The Facebook application identifier. + * @param permissions + * A list of permissions required for this application. If you do + * not require any permissions, pass an empty String array. + */ + private void startDialogAuth(Activity activity, String[] permissions) { + Bundle params = new Bundle(); + if (permissions.length > 0) { + params.putString("scope", TextUtils.join(",", permissions)); + } + CookieSyncManager.createInstance(activity); + dialog(activity, LOGIN, params, new DialogListener() { + + public void onComplete(Bundle values) { + // ensure any cookies set by the dialog are saved + CookieSyncManager.getInstance().sync(); + setAccessToken(values.getString(TOKEN)); + setAccessExpiresIn(values.getString(EXPIRES)); + if (isSessionValid()) { + Log.d("Facebook-authorize", "Login Success! access_token=" + + getAccessToken() + " expires=" + + getAccessExpires()); + mAuthDialogListener.onComplete(values); + } else { + mAuthDialogListener.onFacebookError(new FacebookError( + "Failed to receive access token.")); + } + } + + public void onError(DialogError error) { + Log.d("Facebook-authorize", "Login failed: " + error); + mAuthDialogListener.onError(error); + } + + public void onFacebookError(FacebookError error) { + Log.d("Facebook-authorize", "Login failed: " + error); + mAuthDialogListener.onFacebookError(error); + } + + public void onCancel() { + Log.d("Facebook-authorize", "Login canceled"); + mAuthDialogListener.onCancel(); + } + }); + } + + /** + * IMPORTANT: This method must be invoked at the top of the calling + * activity's onActivityResult() function or Facebook authentication will + * not function properly! + * + * If your calling activity does not currently implement onActivityResult(), + * you must implement it and include a call to this method if you intend to + * use the authorize() method in this SDK. + * + * For more information, see + * http://developer.android.com/reference/android/app/ + * Activity.html#onActivityResult(int, int, android.content.Intent) + */ + public void authorizeCallback(int requestCode, int resultCode, Intent data) { + if (requestCode == mAuthActivityCode) { + + // Successfully redirected. + if (resultCode == Activity.RESULT_OK) { + + // Check OAuth 2.0/2.10 error code. + String error = data.getStringExtra("error"); + if (error == null) { + error = data.getStringExtra("error_type"); + } + + // A Facebook error occurred. + if (error != null) { + if (error.equals(SINGLE_SIGN_ON_DISABLED) + || error.equals("AndroidAuthKillSwitchException")) { + Log.d("Facebook-authorize", "Hosted auth currently " + + "disabled. Retrying dialog auth..."); + startDialogAuth(mAuthActivity, mAuthPermissions); + } else if (error.equals("access_denied") + || error.equals("OAuthAccessDeniedException")) { + Log.d("Facebook-authorize", "Login canceled by user."); + mAuthDialogListener.onCancel(); + } else { + Log.d("Facebook-authorize", "Login failed: " + error); + mAuthDialogListener.onFacebookError( + new FacebookError(error)); + } + + // No errors. + } else { + setAccessToken(data.getStringExtra(TOKEN)); + setAccessExpiresIn(data.getStringExtra(EXPIRES)); + if (isSessionValid()) { + Log.d("Facebook-authorize", + "Login Success! access_token=" + + getAccessToken() + " expires=" + + getAccessExpires()); + mAuthDialogListener.onComplete(data.getExtras()); + } else { + mAuthDialogListener.onFacebookError(new FacebookError( + "Failed to receive access token.")); + } + } + + // An error occurred before we could be redirected. + } else if (resultCode == Activity.RESULT_CANCELED) { + + // An Android error occured. + if (data != null) { + Log.d("Facebook-authorize", + "Login failed: " + data.getStringExtra("error")); + mAuthDialogListener.onError( + new DialogError( + data.getStringExtra("error"), + data.getIntExtra("error_code", -1), + data.getStringExtra("failing_url"))); + + // User pressed the 'back' button. + } else { + Log.d("Facebook-authorize", "Login canceled by user."); + mAuthDialogListener.onCancel(); + } + } + } + } + + /** + * Invalidate the current user session by removing the access token in + * memory, clearing the browser cookie, and calling auth.expireSession + * through the API. + * + * Note that this method blocks waiting for a network response, so do not + * call it in a UI thread. + * + * @param context + * The Android context in which the logout should be called: it + * should be the same context in which the login occurred in + * order to clear any stored cookies + * @throws IOException + * @throws MalformedURLException + * @return JSON string representation of the auth.expireSession response + * ("true" if successful) + */ + public String logout(Context context) + throws MalformedURLException, IOException { + Util.clearCookies(context); + Bundle b = new Bundle(); + b.putString("method", "auth.expireSession"); + String response = request(b); + setAccessToken(null); + setAccessExpires(0); + return response; + } + + /** + * Make a request to Facebook's old (pre-graph) API with the given + * parameters. One of the parameter keys must be "method" and its value + * should be a valid REST server API method. + * + * See http://developers.facebook.com/docs/reference/rest/ + * + * Note that this method blocks waiting for a network response, so do not + * call it in a UI thread. + * + * Example: + * + * Bundle parameters = new Bundle(); + * parameters.putString("method", "auth.expireSession"); + * String response = request(parameters); + * + * + * @param parameters + * Key-value pairs of parameters to the request. Refer to the + * documentation: one of the parameters must be "method". + * @throws IOException + * if a network error occurs + * @throws MalformedURLException + * if accessing an invalid endpoint + * @throws IllegalArgumentException + * if one of the parameters is not "method" + * @return JSON string representation of the response + */ + public String request(Bundle parameters) + throws MalformedURLException, IOException { + if (!parameters.containsKey("method")) { + throw new IllegalArgumentException("API method must be specified. " + + "(parameters must contain key \"method\" and value). See" + + " http://developers.facebook.com/docs/reference/rest/"); + } + return request(null, parameters, "GET"); + } + + /** + * Make a request to the Facebook Graph API without any parameters. + * + * See http://developers.facebook.com/docs/api + * + * Note that this method blocks waiting for a network response, so do not + * call it in a UI thread. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @throws IOException + * @throws MalformedURLException + * @return JSON string representation of the response + */ + public String request(String graphPath) + throws MalformedURLException, IOException { + return request(graphPath, new Bundle(), "GET"); + } + + /** + * Make a request to the Facebook Graph API with the given string parameters + * using an HTTP GET (default method). + * + * See http://developers.facebook.com/docs/api + * + * Note that this method blocks waiting for a network response, so do not + * call it in a UI thread. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @param parameters + * key-value string parameters, e.g. the path "search" with + * parameters "q" : "facebook" would produce a query for the + * following graph resource: + * https://graph.facebook.com/search?q=facebook + * @throws IOException + * @throws MalformedURLException + * @return JSON string representation of the response + */ + public String request(String graphPath, Bundle parameters) + throws MalformedURLException, IOException { + return request(graphPath, parameters, "GET"); + } + + /** + * Synchronously make a request to the Facebook Graph API with the given + * HTTP method and string parameters. Note that binary data parameters + * (e.g. pictures) are not yet supported by this helper function. + * + * See http://developers.facebook.com/docs/api + * + * Note that this method blocks waiting for a network response, so do not + * call it in a UI thread. + * + * @param graphPath + * Path to resource in the Facebook graph, e.g., to fetch data + * about the currently logged authenticated user, provide "me", + * which will fetch http://graph.facebook.com/me + * @param params + * Key-value string parameters, e.g. the path "search" with + * parameters {"q" : "facebook"} would produce a query for the + * following graph resource: + * https://graph.facebook.com/search?q=facebook + * @param httpMethod + * http verb, e.g. "GET", "POST", "DELETE" + * @throws IOException + * @throws MalformedURLException + * @return JSON string representation of the response + */ + public String request(String graphPath, Bundle params, String httpMethod) + throws FileNotFoundException, MalformedURLException, IOException { + params.putString("format", "json"); + if (isSessionValid()) { + params.putString(TOKEN, getAccessToken()); + } + String url = (graphPath != null) ? GRAPH_BASE_URL + graphPath + : RESTSERVER_URL; + return Util.openUrl(url, httpMethod, params); + } + + /** + * Generate a UI dialog for the request action in the given Android context. + * + * Note that this method is asynchronous and the callback will be invoked in + * the original calling thread (not in a background thread). + * + * @param context + * The Android context in which we will generate this dialog. + * @param action + * String representation of the desired method: e.g. "login", + * "stream.publish", ... + * @param listener + * Callback interface to notify the application when the dialog + * has completed. + */ + public void dialog(Context context, String action, + DialogListener listener) { + dialog(context, action, new Bundle(), listener); + } + + /** + * Generate a UI dialog for the request action in the given Android context + * with the provided parameters. + * + * Note that this method is asynchronous and the callback will be invoked in + * the original calling thread (not in a background thread). + * + * @param context + * The Android context in which we will generate this dialog. + * @param action + * String representation of the desired method: e.g. "feed" ... + * @param parameters + * String key-value pairs to be passed as URL parameters. + * @param listener + * Callback interface to notify the application when the dialog + * has completed. + */ + public void dialog(Context context, String action, Bundle parameters, + final DialogListener listener) { + + String endpoint = DIALOG_BASE_URL + action; + parameters.putString("display", "touch"); + parameters.putString("redirect_uri", REDIRECT_URI); + + if (action.equals(LOGIN)) { + parameters.putString("type", "user_agent"); + parameters.putString("client_id", mAppId); + } else { + parameters.putString("app_id", mAppId); + } + + if (isSessionValid()) { + parameters.putString(TOKEN, getAccessToken()); + } + String url = endpoint + "?" + Util.encodeUrl(parameters); + if (context.checkCallingOrSelfPermission(Manifest.permission.INTERNET) + != PackageManager.PERMISSION_GRANTED) { + Util.showAlert(context, "Error", + "Application requires permission to access the Internet"); + } else { + new FbDialog(context, url, listener).show(); + } + } + + /** + * @return boolean - whether this object has an non-expired session token + */ + public boolean isSessionValid() { + return (getAccessToken() != null) && + ((getAccessExpires() == 0) || + (System.currentTimeMillis() < getAccessExpires())); + } + + /** + * Retrieve the OAuth 2.0 access token for API access: treat with care. + * Returns null if no session exists. + * + * @return String - access token + */ + public String getAccessToken() { + return mAccessToken; + } + + /** + * Retrieve the current session's expiration time (in milliseconds since + * Unix epoch), or 0 if the session doesn't expire or doesn't exist. + * + * @return long - session expiration time + */ + public long getAccessExpires() { + return mAccessExpires; + } + + /** + * Set the OAuth 2.0 access token for API access. + * + * @param token + * - access token + */ + public void setAccessToken(String token) { + mAccessToken = token; + } + + /** + * Set the current session's expiration time (in milliseconds since Unix + * epoch), or 0 if the session doesn't expire. + * + * @param time + * - timestamp in milliseconds + */ + public void setAccessExpires(long time) { + mAccessExpires = time; + } + + /** + * Set the current session's duration (in seconds since Unix epoch). + * + * @param expiresIn + * - duration in seconds + */ + public void setAccessExpiresIn(String expiresIn) { + if (expiresIn != null && !expiresIn.equals("0")) { + setAccessExpires(System.currentTimeMillis() + + Integer.parseInt(expiresIn) * 1000); + } + } + + public String getAppId() { + return mAppId; + } + + public void setAppId(String appId) { + mAppId = appId; + } + + /** + * Callback interface for dialog requests. + * + */ + public static interface DialogListener { + + /** + * Called when a dialog completes. + * + * Executed by the thread that initiated the dialog. + * + * @param values + * Key-value string pairs extracted from the response. + */ + public void onComplete(Bundle values); + + /** + * Called when a Facebook responds to a dialog with an error. + * + * Executed by the thread that initiated the dialog. + * + */ + public void onFacebookError(FacebookError e); + + /** + * Called when a dialog has an error. + * + * Executed by the thread that initiated the dialog. + * + */ + public void onError(DialogError e); + + /** + * Called when a dialog is canceled by the user. + * + * Executed by the thread that initiated the dialog. + * + */ + public void onCancel(); + + } + + public static final String FB_APP_SIGNATURE = + "30820268308201d102044a9c4610300d06092a864886f70d0101040500307a310" + + "b3009060355040613025553310b30090603550408130243413112301006035504" + + "07130950616c6f20416c746f31183016060355040a130f46616365626f6f6b204" + + "d6f62696c653111300f060355040b130846616365626f6f6b311d301b06035504" + + "03131446616365626f6f6b20436f72706f726174696f6e3020170d30393038333" + + "13231353231365a180f32303530303932353231353231365a307a310b30090603" + + "55040613025553310b30090603550408130243413112301006035504071309506" + + "16c6f20416c746f31183016060355040a130f46616365626f6f6b204d6f62696c" + + "653111300f060355040b130846616365626f6f6b311d301b06035504031314466" + + "16365626f6f6b20436f72706f726174696f6e30819f300d06092a864886f70d01" + + "0101050003818d0030818902818100c207d51df8eb8c97d93ba0c8c1002c928fa" + + "b00dc1b42fca5e66e99cc3023ed2d214d822bc59e8e35ddcf5f44c7ae8ade50d7" + + "e0c434f500e6c131f4a2834f987fc46406115de2018ebbb0d5a3c261bd97581cc" + + "fef76afc7135a6d59e8855ecd7eacc8f8737e794c60a761c536b72b11fac8e603" + + "f5da1a2d54aa103b8a13c0dbc10203010001300d06092a864886f70d010104050" + + "0038181005ee9be8bcbb250648d3b741290a82a1c9dc2e76a0af2f2228f1d9f9c" + + "4007529c446a70175c5a900d5141812866db46be6559e2141616483998211f4a6" + + "73149fb2232a10d247663b26a9031e15f84bc1c74d141ff98a02d76f85b2c8ab2" + + "571b6469b232d8e768a7f7ca04f7abe4a775615916c07940656b58717457b42bd" + + "928a2"; + +} diff --git a/facebook/facebook/src/com/facebook/android/FacebookError.java b/facebook/facebook/src/com/facebook/android/FacebookError.java new file mode 100644 index 000000000..16836dbd7 --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/FacebookError.java @@ -0,0 +1,50 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +/** + * Encapsulation of a Facebook Error: a Facebook request that could not be + * fulfilled. + * + * @author ssoneff@facebook.com + */ +public class FacebookError extends Throwable { + + private static final long serialVersionUID = 1L; + + private int mErrorCode = 0; + private String mErrorType; + + public FacebookError(String message) { + super(message); + } + + public FacebookError(String message, String type, int code) { + super(message); + mErrorType = type; + mErrorCode = code; + } + + public int getErrorCode() { + return mErrorCode; + } + + public String getErrorType() { + return mErrorType; + } + +} diff --git a/facebook/facebook/src/com/facebook/android/FbDialog.java b/facebook/facebook/src/com/facebook/android/FbDialog.java new file mode 100644 index 000000000..513c626e7 --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/FbDialog.java @@ -0,0 +1,179 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.Display; +import android.view.ViewGroup; +import android.view.Window; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.facebook.android.Facebook.DialogListener; + +public class FbDialog extends Dialog { + + static final int FB_BLUE = 0xFF6D84B4; + static final float[] DIMENSIONS_LANDSCAPE = {460, 260}; + static final float[] DIMENSIONS_PORTRAIT = {280, 420}; + static final FrameLayout.LayoutParams FILL = + new FrameLayout.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, + ViewGroup.LayoutParams.FILL_PARENT); + static final int MARGIN = 4; + static final int PADDING = 2; + static final String DISPLAY_STRING = "touch"; + static final String FB_ICON = "icon.png"; + + private String mUrl; + private DialogListener mListener; + private ProgressDialog mSpinner; + private WebView mWebView; + private LinearLayout mContent; + private TextView mTitle; + + public FbDialog(Context context, String url, DialogListener listener) { + super(context); + mUrl = url; + mListener = listener; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mSpinner = new ProgressDialog(getContext()); + mSpinner.requestWindowFeature(Window.FEATURE_NO_TITLE); + mSpinner.setMessage("Loading..."); + + mContent = new LinearLayout(getContext()); + mContent.setOrientation(LinearLayout.VERTICAL); + setUpTitle(); + setUpWebView(); + Display display = getWindow().getWindowManager().getDefaultDisplay(); + final float scale = getContext().getResources().getDisplayMetrics().density; + float[] dimensions = + (display.getWidth() < display.getHeight()) + ? DIMENSIONS_PORTRAIT : DIMENSIONS_LANDSCAPE; + addContentView(mContent, new FrameLayout.LayoutParams( + (int) (dimensions[0] * scale + 0.5f), + (int) (dimensions[1] * scale + 0.5f))); + } + + private void setUpTitle() { + requestWindowFeature(Window.FEATURE_NO_TITLE); + Drawable icon = getContext().getResources().getDrawable( + R.drawable.facebook_icon); + mTitle = new TextView(getContext()); + mTitle.setText("Facebook"); + mTitle.setTextColor(Color.WHITE); + mTitle.setTypeface(Typeface.DEFAULT_BOLD); + mTitle.setBackgroundColor(FB_BLUE); + mTitle.setPadding(MARGIN + PADDING, MARGIN, MARGIN, MARGIN); + mTitle.setCompoundDrawablePadding(MARGIN + PADDING); + mTitle.setCompoundDrawablesWithIntrinsicBounds( + icon, null, null, null); + mContent.addView(mTitle); + } + + private void setUpWebView() { + mWebView = new WebView(getContext()); + mWebView.setVerticalScrollBarEnabled(false); + mWebView.setHorizontalScrollBarEnabled(false); + mWebView.setWebViewClient(new FbDialog.FbWebViewClient()); + mWebView.getSettings().setJavaScriptEnabled(true); + mWebView.loadUrl(mUrl); + mWebView.setLayoutParams(FILL); + mContent.addView(mWebView); + } + + private class FbWebViewClient extends WebViewClient { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + Log.d("Facebook-WebView", "Redirect URL: " + url); + if (url.startsWith(Facebook.REDIRECT_URI)) { + Bundle values = Util.parseUrl(url); + + String error = values.getString("error"); + if (error == null) { + error = values.getString("error_type"); + } + + if (error == null) { + mListener.onComplete(values); + } else if (error.equals("access_denied") || + error.equals("OAuthAccessDeniedException")) { + mListener.onCancel(); + } else { + mListener.onFacebookError(new FacebookError(error)); + } + + FbDialog.this.dismiss(); + return true; + } else if (url.startsWith(Facebook.CANCEL_URI)) { + mListener.onCancel(); + FbDialog.this.dismiss(); + return true; + } else if (url.contains(DISPLAY_STRING)) { + return false; + } + // launch non-dialog URLs in a full browser + getContext().startActivity( + new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + return true; + } + + @Override + public void onReceivedError(WebView view, int errorCode, + String description, String failingUrl) { + super.onReceivedError(view, errorCode, description, failingUrl); + mListener.onError( + new DialogError(description, errorCode, failingUrl)); + FbDialog.this.dismiss(); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + Log.d("Facebook-WebView", "Webview loading URL: " + url); + super.onPageStarted(view, url, favicon); + mSpinner.show(); + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + String title = mWebView.getTitle(); + if (title != null && title.length() > 0) { + mTitle.setText(title); + } + mSpinner.dismiss(); + } + + } +} diff --git a/facebook/facebook/src/com/facebook/android/Util.java b/facebook/facebook/src/com/facebook/android/Util.java new file mode 100644 index 000000000..cf5e97b96 --- /dev/null +++ b/facebook/facebook/src/com/facebook/android/Util.java @@ -0,0 +1,301 @@ +/* + * Copyright 2010 Facebook, Inc. + * + * 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.facebook.android; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLEncoder; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.AlertDialog.Builder; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.webkit.CookieManager; +import android.webkit.CookieSyncManager; + +/** + * Utility class supporting the Facebook Object. + * + * @author ssoneff@facebook.com + * + */ +public final class Util { + + /** + * Generate the multi-part post body providing the parameters and boundary + * string + * + * @param parameters the parameters need to be posted + * @param boundary the random string as boundary + * @return a string of the post body + */ + public static String encodePostBody(Bundle parameters, String boundary) { + if (parameters == null) return ""; + StringBuilder sb = new StringBuilder(); + + for (String key : parameters.keySet()) { + if (parameters.getByteArray(key) != null) { + continue; + } + + sb.append("Content-Disposition: form-data; name=\"" + key + + "\"\r\n\r\n" + parameters.getString(key)); + sb.append("\r\n" + "--" + boundary + "\r\n"); + } + + return sb.toString(); + } + + public static String encodeUrl(Bundle parameters) { + if (parameters == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (String key : parameters.keySet()) { + if (first) first = false; else sb.append("&"); + sb.append(URLEncoder.encode(key) + "=" + + URLEncoder.encode(parameters.getString(key))); + } + return sb.toString(); + } + + public static Bundle decodeUrl(String s) { + Bundle params = new Bundle(); + if (s != null) { + String array[] = s.split("&"); + for (String parameter : array) { + String v[] = parameter.split("="); + params.putString(URLDecoder.decode(v[0]), + URLDecoder.decode(v[1])); + } + } + return params; + } + + /** + * Parse a URL query and fragment parameters into a key-value bundle. + * + * @param url the URL to parse + * @return a dictionary bundle of keys and values + */ + public static Bundle parseUrl(String url) { + // hack to prevent MalformedURLException + url = url.replace("fbconnect", "http"); + try { + URL u = new URL(url); + Bundle b = decodeUrl(u.getQuery()); + b.putAll(decodeUrl(u.getRef())); + return b; + } catch (MalformedURLException e) { + return new Bundle(); + } + } + + + /** + * Connect to an HTTP URL and return the response as a string. + * + * Note that the HTTP method override is used on non-GET requests. (i.e. + * requests are made as "POST" with method specified in the body). + * + * @param url - the resource to open: must be a welformed URL + * @param method - the HTTP method to use ("GET", "POST", etc.) + * @param params - the query parameter for the URL (e.g. access_token=foo) + * @return the URL contents as a String + * @throws MalformedURLException - if the URL format is invalid + * @throws IOException - if a network problem occurs + */ + public static String openUrl(String url, String method, Bundle params) + throws MalformedURLException, IOException { + // random string as boundary for multi-part http post + String strBoundary = "3i2ndDfv2rTHiSisAbouNdArYfORhtTPEefj3q2f"; + String endLine = "\r\n"; + + OutputStream os; + + if (method.equals("GET")) { + url = url + "?" + encodeUrl(params); + } + Log.d("Facebook-Util", method + " URL: " + url); + HttpURLConnection conn = + (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", System.getProperties(). + getProperty("http.agent") + " FacebookAndroidSDK"); + if (!method.equals("GET")) { + Bundle dataparams = new Bundle(); + for (String key : params.keySet()) { + if (params.getByteArray(key) != null) { + dataparams.putByteArray(key, params.getByteArray(key)); + } + } + + // use method override + if (!params.containsKey("method")) { + params.putString("method", method); + } + + if (params.containsKey("access_token")) { + String decoded_token = + URLDecoder.decode(params.getString("access_token")); + params.putString("access_token", decoded_token); + } + + conn.setRequestMethod("POST"); + conn.setRequestProperty( + "Content-Type", + "multipart/form-data;boundary="+strBoundary); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setRequestProperty("Connection", "Keep-Alive"); + conn.connect(); + os = new BufferedOutputStream(conn.getOutputStream()); + + os.write(("--" + strBoundary +endLine).getBytes()); + os.write((encodePostBody(params, strBoundary)).getBytes()); + os.write((endLine + "--" + strBoundary + endLine).getBytes()); + + if (!dataparams.isEmpty()) { + + for (String key: dataparams.keySet()){ + os.write(("Content-Disposition: form-data; filename=\"" + key + "\"" + endLine).getBytes()); + os.write(("Content-Type: content/unknown" + endLine + endLine).getBytes()); + os.write(dataparams.getByteArray(key)); + os.write((endLine + "--" + strBoundary + endLine).getBytes()); + + } + } + os.flush(); + } + + String response = ""; + try { + response = read(conn.getInputStream()); + } catch (FileNotFoundException e) { + // Error Stream contains JSON that we can parse to a FB error + response = read(conn.getErrorStream()); + } + return response; + } + + private static String read(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + BufferedReader r = new BufferedReader(new InputStreamReader(in), 1000); + for (String line = r.readLine(); line != null; line = r.readLine()) { + sb.append(line); + } + in.close(); + return sb.toString(); + } + + public static void clearCookies(Context context) { + // Edge case: an illegal state exception is thrown if an instance of + // CookieSyncManager has not be created. CookieSyncManager is normally + // created by a WebKit view, but this might happen if you start the + // app, restore saved state, and click logout before running a UI + // dialog in a WebView -- in which case the app crashes + @SuppressWarnings("unused") + CookieSyncManager cookieSyncMngr = + CookieSyncManager.createInstance(context); + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.removeAllCookie(); + } + + /** + * Parse a server response into a JSON Object. This is a basic + * implementation using org.json.JSONObject representation. More + * sophisticated applications may wish to do their own parsing. + * + * The parsed JSON is checked for a variety of error fields and + * a FacebookException is thrown if an error condition is set, + * populated with the error message and error type or code if + * available. + * + * @param response - string representation of the response + * @return the response as a JSON Object + * @throws JSONException - if the response is not valid JSON + * @throws FacebookError - if an error condition is set + */ + public static JSONObject parseJson(String response) + throws JSONException, FacebookError { + // Edge case: when sending a POST request to /[post_id]/likes + // the return value is 'true' or 'false'. Unfortunately + // these values cause the JSONObject constructor to throw + // an exception. + if (response.equals("false")) { + throw new FacebookError("request failed"); + } + if (response.equals("true")) { + response = "{value : true}"; + } + JSONObject json = new JSONObject(response); + + // errors set by the server are not consistent + // they depend on the method and endpoint + if (json.has("error")) { + JSONObject error = json.getJSONObject("error"); + throw new FacebookError( + error.getString("message"), error.getString("type"), 0); + } + if (json.has("error_code") && json.has("error_msg")) { + throw new FacebookError(json.getString("error_msg"), "", + Integer.parseInt(json.getString("error_code"))); + } + if (json.has("error_code")) { + throw new FacebookError("request failed", "", + Integer.parseInt(json.getString("error_code"))); + } + if (json.has("error_msg")) { + throw new FacebookError(json.getString("error_msg")); + } + if (json.has("error_reason")) { + throw new FacebookError(json.getString("error_reason")); + } + return json; + } + + /** + * Display a simple alert dialog with the given text and title. + * + * @param context + * Android context in which the dialog should be displayed + * @param title + * Alert dialog title + * @param text + * Alert dialog message + */ + public static void showAlert(Context context, String title, String text) { + Builder alertBuilder = new Builder(context); + alertBuilder.setTitle(title); + alertBuilder.setMessage(text); + alertBuilder.create().show(); + } + +} diff --git a/facebook/tests/AndroidManifest.xml b/facebook/tests/AndroidManifest.xml new file mode 100644 index 000000000..35f8aefcb --- /dev/null +++ b/facebook/tests/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/facebook/tests/default.properties b/facebook/tests/default.properties new file mode 100644 index 000000000..6d3f02d2c --- /dev/null +++ b/facebook/tests/default.properties @@ -0,0 +1,12 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system use, +# "build.properties", and override values to adapt the script to your +# project structure. + +android.library.reference.1=../facebook/ +# Project target. +target=android-3 diff --git a/facebook/tests/res/drawable/icon.png b/facebook/tests/res/drawable/icon.png new file mode 100644 index 000000000..a07c69fa5 Binary files /dev/null and b/facebook/tests/res/drawable/icon.png differ diff --git a/facebook/tests/res/layout/main.xml b/facebook/tests/res/layout/main.xml new file mode 100644 index 000000000..1d0daf1c5 --- /dev/null +++ b/facebook/tests/res/layout/main.xml @@ -0,0 +1,73 @@ + + + + + +