Google+ Platform

PhotoHunt: Java on Google App Engine

This variation of PhotoHunt runs as a Java application on Google App Engine. The app demonstrates how to build a social application that uses Google+ Sign-In, personalization, app activities, over-the-air install, and interactive posts.

Google App Engine allows rapid development and deployment of applications and allows your application to scale to a very significant capacity. If you use a different application server, don't worry, this guide explains the important pieces to help understand how to integrate with your existing Java app.

This guide discusses in great detail the aspects of PhotoHunt that relate to the Google+ Platform; however, parts of PhotoHunt that are not directly related to Google+ are not discussed. For more information on how PhotoHunt handles the code that is not discussed, such as uploading photos, see the Google App Engine documentation or the AngularJS documentation.

Browsing the source code

If you would like to browse the source code before digging in too deeply, simply have a look at PhotoHunt's Java repository in GitHub.

Requirements

PhotoHunt Java has the following requirements:

Setting up PhotoHunt

PhotoHunt Java can be set up in three steps: set up App Engine, create a Google API project, and apply the settings from these steps to the source code of PhotoHunt Java.

Step 1: Create a Google App Engine project

You can use the Google App Engine SDK to execute the sample application on a local development server.

  1. Navigate to the Google App Engine Admin Console .
  2. Click Create Application.
  3. Choose an application identifier for your app:
    1. Type an unique Application Identifier into the text box.
    2. Verify that the identifier is available by clicking Check Availability. If not available, try a new identifier.
    3. Note or copy the Application Identifier for use in a later step.
  4. Click Create Application.
  5. Download and install the Google App Engine SDK for Java . You can skip this step if the Google App Engine SDK for Java is installed on your system.

Step 2: Enable the Google+ API

You need to enable the Google+ API for your app. You can do this by creating a project for your app in the Google APIs Console.

To create an APIs Console project, create an OAuth 2.0 client ID, and register your JavaScript origins and redirect URIs:

  1. Go to the Google Developers Console.
  2. Select a project, or create a new one.
  3. In the sidebar on the left, select APIs & auth. APIs is automatically selected.
  4. In the displayed list of APIs, make sure the Google+ API status is set to ON.
  5. In the sidebar on the left, select Credentials.
  6. In the OAuth section of the page, select Create New Client ID.

    In the resulting Create Client ID dialog box, register the origins where your app is allowed to access the Google APIs. The origin is the unique combination of protocol, hostname, and port.

    1. In the Application type section of the dialog, select Web application.
    2. In the Authorized JavaScript origins field, enter the origin for your app. You can enter multiple origins to allow for your app to run on different protocols, domains, or subdomains. Wildcards are not allowed. In the example below, the second URL could be a production URL.
      http://localhost:8888
      http://myproductionurl.example.com
    3. Select Create Client ID.
    4. In the resulting Client ID for web application panel, note or copy the Client ID and Client secret that your app will need to use to access the APIs.

Step 3: Set up the application

Get the latest version of PhotoHunt Java. One way is to use git to clone the application repository.

git clone https://github.com/googleplus/gplus-photohunt-server-java.git

This command creates a gplus-photohunt-server-java directory in your current working directory. You can also download and extract a ZIP file of the project.

  1. On the system command line, run the command:

    java -jar path/to/photohunt/war/WEB-INF/lib/lombok.jar
    Replace path/to/photohunt with the path to the root of your local copy.

  2. Open Eclipse and switch perspectives to use the Java perspective: Window ->Open perspective->Other and choose Java.

  3. Select File->Import and choose General->Existing projects into workspace.
  4. Choose Select root directory and browse to the gplus-photohunt-server-java directory. The PhotoHunt Java project is selected.
  5. Click Finish.
  6. Select Project->Properties. In the Properties dialog, navigate to Google-> AppEngine and check Use Google App Engine. Click OK. This copies the Google App Engine jars and creates the configuration for the installed version of Google App Engine.
  7. Edit the war/WEB-INF/appengine-web.xml file, insert your Google App Engine application identifier that you created in step 1 in the <application> element, and save the file.
  8. In war/js/services.js, replace the value of YOUR_CLIENT_ID with the client ID that you generated in step 2 in the APIs Console.
  9. In src/com/google/plus/samples/photohunt/JsonRestServlet.java, replace the values of YOUR_CLIENT_ID and YOUR_CLIENT_SECRET with the client ID and client secret that you generated in step 2.
  10. Run the project by selecting Run->Run as->Web application. If you do not see this option, open Run->Run configurations and click Apply without making any changes to force the option to appear.
  11. Navigate to http://localhost:8888 to try the app.

Application layout

PhotoHunt Java is designed with a common MVC pattern. The model is represented by the com.google.plus.samples.photohunt.model package, the controller is represented by the servlets in the com.google.plus.samples.photohunt package, and the views are represented by the resources within the war directory, which includes HTML files, images, JavaScript, and CSS stylesheets.

All of the libraries that are required by the Java source code reside in war/WEB-INF/lib. The JAR files and their dependencies for Google App Engine, the Google API Java Client, Lombok, and Objectify are included. The JAR files are bundled to ease the set up process for the application, and to get you working as quickly as possible. If you have additional Java dependencies, put their JAR files in the lib directory, and add them to the project's build path by clicking Project > Properties, selecting Java Build Path, and then clicking Add JARs....

The mapping of URLs to servlets is done in the war/WEB-INF/web.xml file. Edit this file if you add more servlets or you want to change URLs.

The Google App Engine project configuration is set in war/WEB-INF/appengine-web.xml. Edit this file to configure your project settings such as your application's version.

The front-end code for all of the PhotoHunt languages makes use of AngularJS and the Google API JavaScript Client. Both of these items are included and used in multiple places throughout war/index.html and war/js.

Writing PhotoHunt, feature by feature

When users interact with the PhotoHunt web app, there are three situations for how users might discover and use the app:

  • A user discovers the app on their own.
  • A friend invites them to the app by using interactive posts.
  • A pre-existing user of the app finds their friends from Google+ are also using the app.

The following sections explain these situations using the fictional characters: Alice, Bob, Charles, and David.

Introducing Alice, PhotoHunt's newest user

Alice represents a brand new user who discovers PhotoHunt for the first time by doing a web search or a search in the Google Play Store or Apple App Store.

  1. Signs in with Google.
  2. Uploads a photo.
  3. Promotes the photo to Bob by asking him to vote.
  4. Invites Charles to join PhotoHunt.
  5. Views David's photos.
  6. Disconnects from PhotoHunt.

Introducing Alice's friends

Bob

Bob is a friend of Alice, but he's never used PhotoHunt. He received Alice's interactive post notification. He enjoys Alice's photos and wants to vote for her photo.

  1. Sees notification of share from Alice.
  2. Clicks the Vote button in the post.
  3. Signs in with Google.

Charles

Charles is another friend of Alice, and also has never used PhotoHunt.

  1. Sees notification of share from Alice.
  2. Clicks the Join button in the post.
  3. Signs in with Google.

David

David is a friend of Alice who has used PhotoHunt for a long time prior to Alice joining. Alice and David have each other in their circles on Google+.

  1. Alice has already uploaded photos.
  2. David signs in with Google.
  3. David uploads photos.
  4. David and Alice see each other's photos prominently.

PhotoHunt's code for Alice

The implementation to provide Alice's flow through PhotoHunt uses the Google+ Sign-In button, over-the-air install, app personalization, interactive posts, and app activities.

Next, we'll walk through how the app integrates the Google+ platform to provide these features to Alice.

Google+ Sign-In button

Visitors who are not signed in see the Google+ Sign-In button in the top-right of the page. Clicking the button prompts the user to authorize the application if the user is not already connected with the app. They see an OAuth 2.0 permissions dialog that lists the information and services that the app is requesting to access on behalf of the user.

First, an HTML element is added to war/index.html that represents the sign-in button. PhotoHunt uses the standard Google+ Sign-In button rather than rendering a custom button.

<span id="signin" ng-show="immediateFailed">
  <span id="myGsignin"></span>
</span>

In the code above, the important piece is that the inner span has an ID of myGsignin, which is used in the JavaScript in war/js/controllers.js.

The following code has the simple purpose of calling gapi.signin.render(), and providing the correct callback to use when the user is actually signed in:

$scope.signIn = function(authResult) {
  $scope.$apply(function() {
    $scope.processAuth(authResult);
  });
}

$scope.processAuth = function(authResult) {
  $scope.immediateFailed = true;
  if ($scope.isSignedIn) {
    return 0;
  }
  if (authResult['access_token']) {
    $scope.immediateFailed = false;
    // Successfully authorized, create session
    PhotoHuntApi.signIn(authResult).then(function(response) {
      $scope.signedIn(response.data);
    });
  } else if (authResult['error']) {
    if (authResult['error'] == 'immediate_failed') {
      $scope.immediateFailed = true;
    } else {
      console.log('Error:' + authResult['error']);
    }
  }
}

$scope.renderSignIn = function() {
  gapi.signin.render('myGsignin', {
    'callback': $scope.signIn,
    'clientid': Conf.clientId,
    'requestvisibleactions': Conf.requestvisibleactions,
    'scope': Conf.scopes,
    'apppackagename': 'your.photohunt.android.package.name',
    'theme': 'dark',
    'cookiepolicy': Conf.cookiepolicy,
    'accesstype': 'offline'
  });
}

After a user is successfully signed in, a call is made to http://localhost:8888/api/connect to connect the user to the PhotoHunt server, and to retrieve and store some information about the user. This step is done in src/com/google/plus/samples/photohunt/ConnectServlet.java, within the doPost() method:

/**
 * Exposed as `POST /api/connect`.
 *
 * Takes the following payload in the request body.  The payload represents
 * all of the parameters that are required to authorize and connect the user
 * to the app.
 * {
 *   "state":"",
 *   "access_token":"",
 *   "token_type":"",
 *   "expires_in":"",
 *   "code":"",
 *   "id_token":"",
 *   "authuser":"",
 *   "session_state":"",
 *   "prompt":"",
 *   "client_id":"",
 *   "scope":"",
 *   "g_user_cookie_policy":"",
 *   "cookie_policy":"",
 *   "issued_at":"",
 *   "expires_at":"",
 *   "g-oauth-window":""
 * }
 *
 * Returns the following JSON response representing the User that was
 * connected:
 * {
 *   "id":0,
 *   "googleUserId":"",
 *   "googleDisplayName":"",
 *   "googlePublicProfileUrl":"",
 *   "googlePublicProfilePhotoUrl":"",
 *   "googleExpiresAt":0
 * }
 *
 * Issues the following errors along with corresponding HTTP response codes:
 * 401: The error from the Google token verification end point.
 * 500: "Failed to upgrade the authorization code." This can happen during
 *      OAuth v2 code exchange flows.
 * 500: "Failed to read token data from Google."
 *      This response also sends the error from the token verification
 *      response concatenated to the error message.
 * 500: "Failed to query the Google+ API: "
 *      This error also includes the error from the client library
 *      concatenated to the error response.
 * 500: "IOException occurred." The IOException could happen when any
 *      IO-related errors occur such as network connectivity loss or local
 *      file-related errors.
 *
 * @see javax.servlet.http.HttpServlet#doPost(
 *     javax.servlet.http.HttpServletRequest,
 *     javax.servlet.http.HttpServletResponse)
 */
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
  TokenData accessToken = null;
  try {
    // read the token
    accessToken = Jsonifiable.fromJson(req.getReader(), TokenData.class);
  } catch (IOException e) {
    sendError(resp, 400, "Unable to read auth result from request body");
  }
  // Create a credential object.
  GoogleCredential credential = new GoogleCredential.Builder()
      .setJsonFactory(JSON_FACTORY).setTransport(TRANSPORT)
      .setClientSecrets(CLIENT_ID, CLIENT_SECRET).build();
  try {
    if (accessToken.code != null) {
      // exchange the code for a token (Web Frontend)
      GoogleTokenResponse tokenFromExchange = exchangeCode(accessToken);
      credential.setFromTokenResponse(tokenFromExchange);
    } else {
      // use the token received from the client
      credential.setAccessToken(accessToken.access_token)
          .setRefreshToken(accessToken.refresh_token)
          .setExpiresInSeconds(accessToken.expires_in)
          .setExpirationTimeMilliseconds(accessToken.expires_at);
    }
    // ensure that we consider logged in the user that owns the access token
    String tokenGoogleUserId = verifyToken(credential);
    User user = saveTokenForUser(tokenGoogleUserId, credential);
    // save the user in the session
    HttpSession session = req.getSession();
    session.setAttribute(CURRENT_USER_SESSION_KEY, user.id);
    generateFriends(user, credential);
    sendResponse(resp, user);
  } catch (TokenVerificationException e) {
    sendError(resp, 401, e.getMessage());
  } catch (TokenResponseException e) {
    sendError(resp, 500, "Failed to upgrade the authorization code.");
  } catch (TokenDataException e) {
    sendError(resp, 500,
        "Failed to read token data from Google. " + e.getMessage());
  } catch (IOException e) {
    sendError(resp, 500, e.getMessage());
  } catch (GoogleApiException e) {
    sendError(resp, 500, "Failed to query the Google+ API: " + e.getMessage());
  }
}

As the comments for the method state, the doPost() method takes the token data given by the Google+ Sign-In button callback, and upgrades the attached authorization code into a fully-qualified access token and refresh token pair. This token data is specific to Alice. Then, this token pair is stored along with the User object that represents Alice in our datastore. If your app has an existing Facebook or Twitter sign-in integration, this is the point at which you would create the relevant new fields that are mentioned in src/com/google/plus/samples/photohunt/model/User.java, including:

  • googleUserId
  • googleDisplayName
  • googlePublicProfileUrl
  • googlePublicProfilePhotoUrl
  • googleAccessToken
  • googleRefreshToken
  • googleExpiresIn
  • googleExpiresAt

doPost() has only set the googleUserId, googleAccessToken, googleRefreshToken, googleExpiresIn, and googleExpiresAt fields for Alice. The display name, public profile URL, and public profile photo URL will be set later when the app uses the Google+ APIs to personalize PhotoHunt for Alice.

After the token is stored, the app sets Alice's ID in her session so that the app can use the ID in future requests. The manner in which you would do cross-request user identification in a real application varies, but this is one way.

Finally, doPost() fetches a list of the people that Alice chose to share with PhotoHunt from her Google+ circles. Fetching a user's social graph is discussed below in app personalization.

Over-the-air install

With one line of code, you can enable the over-the-air install feature to prompt your Android users to install your Android app when they sign in to your web app. The following example highlights this parameter:

$scope.renderSignIn = function() {
  gapi.signin.render('myGsignin', {
    // ...
    'apppackagename': 'your.photohunt.android.package.name',
    // ...
  });
}

Be sure to replace your.photohunt.android.package.name with the package name for your own Android app.

After adding the apppackagename parameter, the over-the-air install happens automatically if certain conditions are met:

App personalization

PhotoHunt is personalized for Alice in two ways:

  1. Her Google+ name, profile photo, and profile link are shown in the PhotoHunt interface.
  2. She automatically sees photos from the people that she chose to share with PhotoHunt, who have also chosen to share photos with Alice.

Alice's name, profile photo, and profile link are retrieved when the /api/connect endpoint is called. This is done as part of the saveTokenForUser() method.

/**
 * Either:
 * 1. Create a user for the given ID and credential
 * 2. or, update the existing user with the existing credential
 *
 * If 2, then ask Google for the user's public profile information to store.
 *
 * @param tokenGoogleUserId Google user ID to update.
 * @param credential Credential to set for the user.
 * @return Updated User.
 * @throws GoogleApiException Could not fetch profile info for user.
 */
private User saveTokenForUser(String tokenGoogleUserId,
    GoogleCredential credential) throws GoogleApiException {
  User user = ofy().load().type(User.class)
      .filter("googleUserId", tokenGoogleUserId).first().get();
  if (user == null) {
    // Register a new user.  Collect their Google profile info first.
    Plus plus = new Plus.Builder(TRANSPORT, JSON_FACTORY, credential).build();
    Person profile;
    Plus.People.Get get;
    try {
      get = plus.people().get("me");
      profile = get.execute();
    } catch (IOException e) {
      throw new GoogleApiException(e.getMessage());
    }
    user = new User();
    user.setGoogleUserId(profile.getId());
    user.setGoogleDisplayName(profile.getDisplayName());
    user.setGooglePublicProfileUrl(profile.getUrl());
    user.setGooglePublicProfilePhotoUrl(profile.getImage().getUrl());
  }
  // TODO(silvano): Also fetch and set the email address for the user.
  user.setGoogleAccessToken(credential.getAccessToken());
  if (credential.getRefreshToken() != null) {
    user.setGoogleRefreshToken(credential.getRefreshToken());
  }
  user.setGoogleExpiresAt(credential.getExpirationTimeMilliseconds());
  user.setGoogleExpiresIn(credential.getExpiresInSeconds());
  ofy().save().entity(user).now();
  return user;
}

The relevant lines are those within the if (user == null) { block. A Google API Java Client instance is created for the Google+ API, and then a call is made to the people.get endpoint of the Google+ API, with a user ID of me. In this case, because the client is authorized for Alice, me tells Google+ to return Alice's public profile information.

The last step of the call to /api/connect is to fetch a list of the people that Alice chose to share with PhotoHunt from her Google+ circles. This is done by the generateFriends() method.

/**
 * Query Google for the list of the user's friends that they've shared with
 * our app, and then store those friends for later use.
 * @param user User for which to get friends.
 * @param credential Credential to use to authorize people.list request.
 * @throws IOException Unable to fetch friends because of network error.
 */
private void generateFriends(User user, GoogleCredential credential)
    throws IOException {
  Plus plus = new Plus.Builder(TRANSPORT, JSON_FACTORY, credential).build();
  Plus.People.List get;
  List<DirectedUserToUserEdge> friends = ofy().load()
      .type(DirectedUserToUserEdge.class)
      .filter("ownerUserId", user.getId()).list();
  ofy().delete().entities(friends);

  get = plus.people().list(user.getGoogleUserId(), "visible");
  PeopleFeed feed = get.execute();
  boolean done = false;
  do {
    for (Person googlePlusPerson : feed.getItems()) {
      User friend = ofy().load().type(User.class).filter(
          "googleUserId", googlePlusPerson.getId()).first().get();
      if (friend != null) {
        DirectedUserToUserEdge friendEdge = new DirectedUserToUserEdge();
        friendEdge.setOwnerUserId(user.getId());
        friendEdge.setFriendUserId(friend.getId());
        ofy().save().entity(friendEdge).now();
      }
    }
    done = true;
  } while (!done);
}

This method is meant to highlight how to retrieve a list of people, but not how to do it efficiently for your own application. This method can take a few seconds to run if the user has a large number of people they've chosen to share with PhotoHunt. Because of this issue, we recommend that you fork this process into a thread, task queue, cron job, or some other asynchronous execution mechanism.

The method creates another Google API Java Client instance for Google+, and calls the people.list endpoint while authorized as Alice. This method returns all of the people that Alice has chosen to share with PhotoHunt, which is in paginated form.

Interactive posts

Interactive posts allow Alice to promote her photos that she uploads to PhotoHunt, and to invite her friends to PhotoHunt.

First, a button is added to war/index.html to allow Alice to invite her friends.

<button id="invite" class="button icon add primary" ng-show="themes">
  Invite your friends
</button>

This invite button is rendered as an interactive post button in the $scope.start function in the war/index.html file. This function runs each time that the index page is loaded:

$scope.start = function() {
  $scope.renderSignIn();
  $scope.checkForHighlightedPhoto();
  PhotoHuntApi.getThemes().then(function(response) {
    $scope.themes = response.data;
    $scope.selectedTheme = $scope.themes[0];
    $scope.orderBy('recent');
    $scope.getUserPhotos();
    var options = {
      'clientid': Conf.clientId,
      'contenturl': Conf.rootUrl + '/invite.html',
      'contentdeeplinkid': '/',
      'prefilltext': 'Join the hunt, upload and vote for photos of ' +
          $scope.selectedTheme.displayName + '. #photohunt',
      'calltoactionlabel': 'Join',
      'calltoactionurl': Conf.rootUrl,
      'calltoactiondeeplinkid': '/',
      'requestvisibleactions': Conf.requestvisibleactions,
      'scope': Conf.scopes,
      'cookiepolicy': Conf.cookiepolicy
    };
    gapi.interactivepost.render('invite', options);
    $scope.getAllPhotos();
  });
}

You can see all of the options that are provided to the gapi.interactivepost.render() method and importantly the contentUrl property. This property is important because that is the URL where Google crawls to create a share snippet for the Google+ stream such as the title for the page and the thumbnail to use. In this case, Google is directed to crawl invite.html which renders a simple page containing the metadata for the invite interactive post. If a user browses to that URL directly, we redirect them to the index page by setting the window.location.href property using JavaScript. When Google crawls the invite.html page for metadata to display in the interactive post, the crawler will not execute JavaScript and so will not be redirected.

<%-- Generates schema.org microdata that can be parsed to populate snippet
for invite interactive post--%>
<%@ page contentType="text/html;charset=UTF-8" language="java"%>
<%@ page import="com.google.plus.samples.photohunt.model.Theme"%>
<%@ page import="com.google.plus.samples.photohunt.model.Photo"%>
<%@ page import="static com.google.plus.samples.photohunt.model.OfyService.ofy"%>
<%@ page import="java.util.List"%>
<%

String imageUrl = "";
String name = "";

Theme currentTheme = Theme.getCurrentTheme();
Photo featPhoto = ofy().load().type(Photo.class)
    .filter("themeId", currentTheme.getId()).first().get();

if (featPhoto != null) {
  imageUrl = featPhoto.getThumbnailUrl();
  name = "Photo by " + featPhoto.getOwnerDisplayName() + " for #" +
  currentTheme.getDisplayName().toLowerCase().replaceAll("[\\s,]", "") +
      " | #photohunt";
} else {
  imageUrl = "/images/interactivepost-icon.png";
  name = "";
}
%>
<!DOCTYPE html>
<html>
<head>
  <script type="text/javascript">
    window.location.href = 'index.html';
  </script>
  <title><%= name %></title>
</head>
<body itemscope itemtype="http://schema.org/Thing">
  <h1 itemprop="name"><%= name %></h1>
  <img itemprop="image" src="<%= imageUrl %>" />
</body>
</html>

Next, a button is added to each photo that PhotoHunt lists, allowing Alice to promote photos. This addition is done in an AngularJS partial, which is located in the war/partials/photo.html file.

<button class="button">Promote</button>

Similarly to the invite button, the promote button is made useful with JavaScript, but this time it is made so using an AngularJS directive while rendering the war/partials/photo.html partial for each photo that is listed on the page. The relevant JavaScript is in the war/js/directives.js file.

angular.module('photoHunt.directives', ['photoHunt.services'])
    .directive('photo', function(Conf, PhotoHuntApi) {
      return {
        restrict: 'E',
        replace: true,
        scope: {
          item: '=',
          deletePhoto: '&deletePhoto'
        },
        templateUrl: 'partials/photo.html',
        link: function (scope, element, attrs) {
          element.find('.voteButton')
              .click(function(evt) {
                if(scope.item.canVote && !scope.item.voted) {
                  var voteButton = angular.element(evt.target)
                  scope.$apply(function() {
                    voteButton.unbind('click');
                    scope.item.numVotes = scope.item.numVotes + 1;
                    scope.item.voted = true;
                    voteButton.focus();
                    scope.item.voteClass.push('disable');
                  });
                  PhotoHuntApi.votePhoto(scope.item.id)
                      .then(function(response) {});
                }
              });

          element.find('.remove')
              .click(function() {
                if (scope.item.canDelete) {
                  scope.deletePhoto({photoId: scope.item.id});
                }
              });

          var options = {
            'clientid': Conf.clientId,
            'contenturl': scope.item.photoContentUrl,
            'contentdeeplinkid': '/?id=' + scope.item.id,
            'prefilltext': 'What do you think?  Does this image embody \'' +
                scope.item.themeDisplayName + '\'? #photohunt',
            'calltoactionlabel': 'VOTE',
            'calltoactionurl': scope.item.voteCtaUrl,
            'calltoactiondeeplinkid': '/?id=' + scope.item.id + '&action=VOTE',
            'requestvisibleactions': Conf.requestvisibleactions,
            'scope': Conf.scopes,
            'cookiepolicy': Conf.cookiepolicy
          }
          gapi.interactivepost.render(
              element.find('.toolbar button').get(0), options);
        }
      }
    })

Notice that the options provided for the promote interactive post are similar to the options that are provided for the invite interactive post. As with the invite interactive post, this time we provide a contentUrl, but its value is http://localhost:8888/photo.html?photoId=0000. The JSP behind the scenes of this HTML page also generates schema.org microdata, but this time the microdata is for the particular photo that is being requested.

After these interactive posts are created and shared by Alice, Bob clicks the "Vote" button in her promotion post, while Charles clicks the "Join" button in his invitation post. Bob and Charles go to the respective call-to-action URLs that are provided in the interactive post options.

App activities

The last part of Alice's use of PhotoHunt that involves the Google+ Platform is when Alice takes an action such as voting or uploading a photo. PhotoHunt writes that app activity to Google, enabling Google to show the information to other users when it is most relevant.

In Alice's case, this is done when she uploads a new photo. The doPost() method of src/com/google/plus/samples/photohunt/PhotosServlet.java, exposed as http://localhost:8888/api/photos, makes a call to addPhotoToGooglePlusHistory(). This method creates a Moment object and sends it to the moments.insert method to write the activity to Alice's profile.

/**
 * Creates an app activity in Google indicating that the given User has
 * uploaded the given Photo.
 * @param author Creator of Photo.
 * @param photo Photo itself.
 * @param credential Credential with which to authorize request to Google.
 * @throws MomentWritingException Failed to write app activity.
 */
private void addPhotoToGooglePlusHistory(User author, Photo photo,
    GoogleCredential credential) throws MomentWritingException{
  ItemScope target = new ItemScope().setUrl(photo.getPhotoContentUrl());
  Moment content = new Moment().setType(
      "http://schemas.google.com/AddActivity").setTarget(target);
  Plus plus = new Plus.Builder(TRANSPORT, JSON_FACTORY, credential).build();
  try {
    Insert request = plus.moments().insert(author.googleUserId,
        "vault", content);
    Moment moment = request.execute();
  } catch (IOException e) {
    throw new MomentWritingException(e.getMessage());
  }
}

Disconnecting from PhotoHunt

The developer policies require that apps that use the Google APIs provide the ability for users to disconnect and that apps must delete all of the data that they collect about a user from the Google+ API.

This operation is handled in src/com/google/plus/samples/photohunt/DisconnectServlet.java, in the doPost() method. The method:

  1. Deletes all of the friend relationships that Alice shared with PhotoHunt from her Google+ circles.
  2. Deletes all of Alice's votes and photos (optional).
  3. Deletes the User object that represents Alice. This is done to delete all of Alice's Google information, but otherwise deleting the entire User object is not necessary in your app.
  4. Disconnects the app from Alice's account by revoking all of the authorization tokens that are issued to PhotoHunt for Alice. This step revokes tokens across the web, Android, and iOS clients.

    /**
     * Exposed as `POST /api/disconnect`.
     *
     * As required by the Google+ Platform Terms of Service, this end-point:
     *
     *   1. Deletes all data retrieved from Google that is stored in our app.
     *   2. Revokes all of the user's tokens issued to this app.
     *
     * Takes no request payload, and disconnects the user currently identified
     * by their session.
     *
     * Returns the following JSON response representing the User that was
     * connected:
     *
     *   "Successfully disconnected."
     *
     * Issues the following errors along with corresponding HTTP response codes:
     * 401: "Unauthorized request".  No user was connected to disconnect.
     * 500: "Failed to revoke token for given user: "
     *      + error from failed connection to revoke end-point.
     *
     * @see javax.servlet.http.HttpServlet#doPost(
     *     javax.servlet.http.HttpServletRequest,
     *     javax.servlet.http.HttpServletResponse)
     */
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
      try {
        checkAuthorization(req);
        Long userId = Long.parseLong(req.getSession()
            .getAttribute(CURRENT_USER_SESSION_KEY).toString());
        List<DirectedUserToUserEdge> edges = ofy().load()
            .type(DirectedUserToUserEdge.class)
            .filter("ownerUserId", userId).list();
        ofy().delete().entities(edges);
        List<Vote> userVotes = ofy().load().type(Vote.class)
            .filter("ownerUserId", userId).list();
        ofy().delete().entities(userVotes);
        List<Photo> userPhotos = ofy().load().type(Photo.class)
            .filter("ownerUserId", userId).list();
        ofy().delete().entities(userPhotos);
        User user = ofy().load().type(User.class).id(userId).get();
        ofy().delete().entity(user);
    
        revokeToken(user.getGoogleAccessToken());
    
        req.getSession().removeAttribute(CURRENT_USER_SESSION_KEY);
        sendResponse(resp, "Successfully disconnected.");
      } catch (UserNotAuthorizedException e) {
        sendError(resp, 401,
            "Unauthorized request");
      } catch (IOException e) {
        sendError(resp, 500,
            "Failed to revoke token for given user: " + e.getMessage());
      }
    }
    
    /**
     * Revoke the given access token, and consequently any other access tokens
     * and refresh tokens issued for this user to this app.
     *
     * Essentially this operation disconnects a user from the app, but keeps
     * their app activities alive in Google.  The same user can later come back
     * to the app, sign-in, re-consent, and resume using the app.
     * @throws IOException Network error occured while making request.
     */
    protected static void revokeToken(String accessToken) throws IOException {
      TRANSPORT.createRequestFactory().buildGetRequest(new GenericUrl(
          String.format(
            "https://accounts.google.com/o/oauth2/revoke?token=%s",
              accessToken))).execute();
    }
    

Reusing the pieces for other users

Bob's interaction with PhotoHunt

Bob's flow touches interactive posts, the Google+ Sign-In flow (note that this is different from the button itself), app personalization, and app activities. His flow touches these pieces in the given order.

As a reminder, Bob is not currently a PhotoHunt user and his interaction with the app is:

  1. Bob views a notification of Alice's interactive share post.
  2. He clicks the Vote button in the interactive post.
  3. Bob signs in with Google+ to create an account on PhotoHunt.

When Bob sees the notification of the share from Alice, and clicks the Vote button, he is actually clicking a link to the call-to-action URL if Bob is originating from web, or he is clicking a deep link to the call-to-action deep link ID if he is originating from Android or iOS.

Next, Bob is taken to PhotoHunt and clicks the Google+ Sign-In button. After signing in, Bob's vote can be recorded. Although PhotoHunt doesn't do this, your app could require the user to sign-in after landing on the page from an interactive post.

Charles' invite

Charles' flow touches the same pieces of the Google+ platform as Bob's flow, but for a different reason, which is to join PhotoHunt instead of to vote on a photo. Otherwise, the case is already supported by the same code we wrote for Alice.

David's photos

When David signed in to PhotoHunt for the first time he chose to give PhotoHunt access to his circles, which include Alice. What is important to understand about the relationship between Alice and David is that David's photos are shown to Alice in her "Photos by Friends" section if and only if the following conditions are met:

  1. David has Alice in his Google+ circles
  2. David has chosen to tell PhotoHunt that he has Alice in his Google+ circles
  3. Alice has David in her Google+ circles
  4. Alice has chosen to tell PhotoHunt that she has David in her Google+ circles

Connections between two users in Google+ are independent of each other. For example, Alice might have David in her circles but David might not have Alice in his circles. Your app should respect the user's preferences and not leak data to people who your user does not want to share with.

Next steps

After you are done working through PhotoHunt, and you thoroughly understand how each feature is implemented, you should spend some time applying each feature to your own app. PhotoHunt is intentionally written as a complete end-to-end application so that you can adapt the concepts easily to your own app.

As you integrate your app with the Google+ Platform, you will likely need to make use of the API reference or the Android or iOS mobile SDKs.

Authentication required

You need to be signed in with Google+ to do that.

Signing you in...

Google Developers needs your permission to do that.