diff --git a/appengine/firebase-tictactoe/README.md b/appengine/firebase-tictactoe/README.md new file mode 100644 index 00000000000..34c62306300 --- /dev/null +++ b/appengine/firebase-tictactoe/README.md @@ -0,0 +1,59 @@ +# Tic Tac Toe on Google App Engine Standard using Firebase + +This directory contains a project that implements a realtime two-player game of +Tic Tac Toe on Google [App Engine Standard][standard], using the [Firebase] database +for realtime notifications when the board changes. + +[Firebase]: https://firebase.google.com +[standard]: https://cloud.google.com/appengine/docs/about-the-standard-environment + +## Prerequisites + +* Install [Apache Maven][maven] 3.0.5 or later +* Install the [Google Cloud SDK][sdk] +* Create a project in the [Firebase Console][fb-console] +* In the [Overview section][fb-overview] of the Firebase console, click 'Add + Firebase to your web app' and replace the contents of the file + `src/main/webapp/WEB-INF/view/firebase_config.jspf` with that code snippet. +* If using the development appserver to run the app locally, you must supply + credentials that would otherwise be inferred from the App Engine environment. + Download [service account credentials][creds] and set the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable to its path: + + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your/credentials.json + + +[fb-console]: https://console.firebase.google.com +[sdk]: https://cloud.google.com/sdk +[creds]: https://console.firebase.google.com/iam-admin/serviceaccounts/project?project=_&consoleReturnUrl=https:%2F%2Fconsole.firebase.google.com%2Fproject%2F_%2Fsettings%2Fgeneral%2F +[fb-overview]: https://console.firebase.google.com/project/_/overview + + +## Run the sample + +* To run the app locally using the development appserver: + + ```sh + $ mvn appengine:run + ``` + +## Troubleshooting + +* If you see the error `Google Cloud SDK path was not provided ...`: + * Make sure you've installed the [Google Cloud SDK][sdk] + * Make sure the Google Cloud SDK's `bin/` directory is in your `PATH`. If + you prefer it not to be, you can also set the environment variable + `GOOGLE_CLOUD_SDK_HOME` to point to where you installed the SDK: + + ```sh + export GOOGLE_CLOUD_SDK_HOME=/path/to/google-cloud-sdk + ``` + +## Contributing changes + +See [CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Licensing + +See [LICENSE](../../LICENSE). + diff --git a/appengine/firebase-tictactoe/pom.xml b/appengine/firebase-tictactoe/pom.xml new file mode 100644 index 00000000000..559d2b76d16 --- /dev/null +++ b/appengine/firebase-tictactoe/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + war + 1.0-SNAPSHOT + com.example.appengine + appengine-firebase + + com.google.cloud + doc-samples + 1.0.0 + ../.. + + + 5.1.13 + 2.5 + 2.7 + 19.0 + 1.22.0 + 4.12 + 1.10.19 + 0.30 + 1.0.0 + + + + 3.3.9 + + + + + com.google.appengine + appengine-api-1.0-sdk + ${appengine.sdk.version} + + + javax.servlet + servlet-api + ${servlet-api.version} + jar + provided + + + com.google.code.gson + gson + 2.7 + + + com.googlecode.objectify + objectify + ${objectify.version} + + + com.google.guava + guava + ${guava.version} + + + com.google.api-client + google-api-client-appengine + ${google-api-client.version} + + + + + + junit + junit + ${junit.version} + test + + + org.mockito + mockito-all + ${mockito.version} + test + + + com.google.appengine + appengine-testing + ${appengine.sdk.version} + test + + + com.google.appengine + appengine-api-stubs + ${appengine.sdk.version} + test + + + com.google.appengine + appengine-tools-sdk + ${appengine.sdk.version} + test + + + com.google.truth + truth + ${google-truth.version} + test + + + + + ${project.build.directory}/${project.build.finalName}/WEB-INF/classes + + + + com.google.cloud.tools + appengine-maven-plugin + ${appengine-maven.version} + + + + diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/DeleteServlet.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/DeleteServlet.java new file mode 100644 index 00000000000..7444191e419 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/DeleteServlet.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.google.appengine.api.users.UserService; +import com.google.appengine.api.users.UserServiceFactory; +import com.googlecode.objectify.Objectify; +import com.googlecode.objectify.ObjectifyService; + +import java.io.IOException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Handler that deletes the Firebase database that serves as the realtime communication channel. + * This handler should be invoked after a game has finished, to clean up the channel. + */ +public class DeleteServlet extends HttpServlet { + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + String gameId = request.getParameter("gameKey"); + Objectify ofy = ObjectifyService.ofy(); + Game game = ofy.load().type(Game.class).id(gameId).safe(); + + UserService userService = UserServiceFactory.getUserService(); + String currentUserId = userService.getCurrentUser().getUserId(); + + game.deleteChannel(currentUserId); + } +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/FirebaseChannel.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/FirebaseChannel.java new file mode 100644 index 00000000000..7bebf9adcfa --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/FirebaseChannel.java @@ -0,0 +1,166 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.google.api.client.extensions.appengine.http.UrlFetchTransport; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.http.ByteArrayContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.appengine.api.appidentity.AppIdentityService; +import com.google.appengine.api.appidentity.AppIdentityServiceFactory; +import com.google.common.io.BaseEncoding; +import com.google.common.io.CharStreams; +import com.google.gson.Gson; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility functions for communicating with the realtime communication channel using Firebase. + * In this app, we use Firebase as a communication bus to push the state of the board to all clients + * - that is, players of the game. This class contains the methods used to communicate with + * Firebase. + */ +public class FirebaseChannel { + private static final String FIREBASE_SNIPPET_PATH = "WEB-INF/view/firebase_config.jspf"; + private static final Collection FIREBASE_SCOPES = Arrays.asList( + "https://www.googleapis.com/auth/firebase.database", + "https://www.googleapis.com/auth/userinfo.email" + ); + private static final String IDENTITY_ENDPOINT = + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; + static final HttpTransport HTTP_TRANSPORT = new UrlFetchTransport(); + + private String firebaseDbUrl; + private GoogleCredential credential; + + private static FirebaseChannel instance; + + /** + * FirebaseChannel is a singleton, since it's just utility functions. + * The class derives auth information when first instantiated. + */ + public static FirebaseChannel getInstance() { + if (instance == null) { + instance = new FirebaseChannel(); + } + return instance; + } + + /** + * Construct the singleton, with derived auth information. The Firebase database url is derived + * from the snippet that we provide to the client code, to guarantee that the client and the + * server are communicating with the same Firebase database. The auth credentials we'll use to + * communicate with Firebase is derived from App Engine's default credentials, and given + * Firebase's OAuth scopes. + */ + private FirebaseChannel() { + try { + String firebaseSnippet = CharStreams.toString(new InputStreamReader( + new FileInputStream(FIREBASE_SNIPPET_PATH), StandardCharsets.UTF_8)); + firebaseDbUrl = parseFirebaseUrl(firebaseSnippet); + + credential = GoogleCredential.getApplicationDefault().createScoped(FIREBASE_SCOPES); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Parses out the Firebase database url from the client-side code snippet. + * The code snippet is a piece of javascript that defines an object with the key 'databaseURL'. So + * look for that key, then parse out its quote-surrounded value. + */ + private static String parseFirebaseUrl(String firebaseSnippet) { + int idx = firebaseSnippet.indexOf("databaseURL"); + if (-1 == idx) { + throw new RuntimeException( + "Please copy your Firebase web snippet into " + FIREBASE_SNIPPET_PATH); + } + idx = firebaseSnippet.indexOf(':', idx); + int openQuote = firebaseSnippet.indexOf('"', idx); + int closeQuote = firebaseSnippet.indexOf('"', openQuote + 1); + return firebaseSnippet.substring(openQuote + 1, closeQuote); + } + + public void sendFirebaseMessage(String channelKey, Game game) + throws IOException { + // Make requests auth'ed using Application Default Credentials + HttpRequestFactory requestFactory = HTTP_TRANSPORT.createRequestFactory(credential); + GenericUrl url = new GenericUrl( + String.format("%s/channels/%s.json", firebaseDbUrl, channelKey)); + HttpResponse response = null; + + try { + if (null == game) { + response = requestFactory.buildDeleteRequest(url).execute(); + } else { + String gameJson = new Gson().toJson(game); + response = requestFactory.buildPatchRequest( + url, new ByteArrayContent("application/json", gameJson.getBytes())).execute(); + } + + if (response.getStatusCode() != 200) { + throw new RuntimeException( + "Error code while updating Firebase: " + response.getStatusCode()); + } + + } finally { + if (null != response) { + response.disconnect(); + } + } + } + + /** + * Create a secure JWT token for the given userId. + */ + public String createFirebaseToken(Game game, String userId) { + final AppIdentityService appIdentity = AppIdentityServiceFactory.getAppIdentityService(); + final BaseEncoding base64 = BaseEncoding.base64(); + + String header = base64.encode("{\"typ\":\"JWT\",\"alg\":\"RS256\"}".getBytes()); + + // Construct the claim + String channelKey = game.getChannelKey(userId); + String clientEmail = appIdentity.getServiceAccountName(); + long epochTime = System.currentTimeMillis() / 1000; + long expire = epochTime + 60 * 60; // an hour from now + + Map claims = new HashMap(); + claims.put("iss", clientEmail); + claims.put("sub", clientEmail); + claims.put("aud", IDENTITY_ENDPOINT); + claims.put("uid", channelKey); + claims.put("iat", epochTime); + claims.put("exp", expire); + + String payload = base64.encode(new Gson().toJson(claims).getBytes()); + String toSign = String.format("%s.%s", header, payload); + AppIdentityService.SigningResult result = appIdentity.signForApp(toSign.getBytes()); + return String.format("%s.%s", toSign, base64.encode(result.getSignature())); + } +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/Game.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/Game.java new file mode 100644 index 00000000000..e990a8b1fe8 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/Game.java @@ -0,0 +1,185 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.googlecode.objectify.annotation.Entity; +import com.googlecode.objectify.annotation.Id; + +import java.io.IOException; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * The datastore-persisted Game object. This holds the entire game state - from a representation of + * the board, to the players are and whose turn it is, and who the winner is and how they won. + * + * It also contains some convenience functions for communicating updates to the board to the + * clients, via Firebase. + */ +@Entity +public class Game { + static final Pattern[] XWins = + {Pattern.compile("XXX......"), Pattern.compile("...XXX..."), Pattern.compile("......XXX"), + Pattern.compile("X..X..X.."), Pattern.compile(".X..X..X."), + Pattern.compile("..X..X..X"), Pattern.compile("X...X...X"), + Pattern.compile("..X.X.X..")}; + static final Pattern[] OWins = + {Pattern.compile("OOO......"), Pattern.compile("...OOO..."), Pattern.compile("......OOO"), + Pattern.compile("O..O..O.."), Pattern.compile(".O..O..O."), + Pattern.compile("..O..O..O"), Pattern.compile("O...O...O"), + Pattern.compile("..O.O.O..")}; + + @Id + public String id; + public String userX; + public String userO; + public String board; + public Boolean moveX; + public String winner; + public String winningBoard; + + private static final Logger LOGGER = Logger.getLogger(Game.class.getName()); + + Game() { + this.id = UUID.randomUUID().toString(); + } + + Game(String userX, String userO, String board, boolean moveX) { + this.id = UUID.randomUUID().toString(); + this.userX = userX; + this.userO = userO; + this.board = board; + this.moveX = moveX; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserX() { + return userX; + } + + public String getUserO() { + return userO; + } + + public void setUserO(String userO) { + this.userO = userO; + } + + public String getBoard() { + return board; + } + + public void setBoard(String board) { + this.board = board; + } + + public boolean getMoveX() { + return moveX; + } + + public void setMoveX(boolean moveX) { + this.moveX = moveX; + } + + //[START send_updates] + public String getChannelKey(String userId) { + return userId + id; + } + + public void deleteChannel(String userId) + throws IOException { + if (userId != null) { + String channelKey = getChannelKey(userId); + FirebaseChannel.getInstance().sendFirebaseMessage(channelKey, null); + } + } + + private void sendUpdateToUser(String userId) + throws IOException { + if (userId != null) { + String channelKey = getChannelKey(userId); + FirebaseChannel.getInstance().sendFirebaseMessage(channelKey, this); + } + } + + public void sendUpdateToClients() + throws IOException { + sendUpdateToUser(userX); + sendUpdateToUser(userO); + } + //[END send_updates] + + public void checkWin() { + final Pattern[] wins; + if (moveX) { + wins = XWins; + } else { + wins = OWins; + } + + for (Pattern winPattern : wins) { + if (winPattern.matcher(board).matches()) { + if (moveX) { + winner = userX; + } else { + winner = userO; + } + winningBoard = winPattern.toString(); + } + } + } + + //[START make_move] + public boolean makeMove(int position, String userId) { + String currentMovePlayer; + char value; + if (getMoveX()) { + value = 'X'; + currentMovePlayer = getUserX(); + } else { + value = 'O'; + currentMovePlayer = getUserO(); + } + + if (currentMovePlayer.equals(userId)) { + char[] boardBytes = getBoard().toCharArray(); + boardBytes[position] = value; + setBoard(new String(boardBytes)); + checkWin(); + setMoveX(!getMoveX()); + try { + sendUpdateToClients(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error sending Game update to Firebase", e); + throw new RuntimeException(e); + } + return true; + } + + return false; + } + //[END make_move] +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/MoveServlet.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/MoveServlet.java new file mode 100644 index 00000000000..fffe9d8a15a --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/MoveServlet.java @@ -0,0 +1,52 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.google.appengine.api.users.UserService; +import com.google.appengine.api.users.UserServiceFactory; +import com.googlecode.objectify.Objectify; +import com.googlecode.objectify.ObjectifyService; + +import java.io.IOException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Handler for a user making a move in a game. + * Updates the game board with the requested move (if it's legal), and communicate the updated board + * to the clients. + */ +public class MoveServlet extends HttpServlet { + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + String gameId = request.getParameter("gameKey"); + Objectify ofy = ObjectifyService.ofy(); + Game game = ofy.load().type(Game.class).id(gameId).safe(); + + UserService userService = UserServiceFactory.getUserService(); + String currentUserId = userService.getCurrentUser().getUserId(); + + int cell = new Integer(request.getParameter("cell")); + if (!game.makeMove(cell, currentUserId)) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } else { + ofy.save().entity(game).now(); + } + } +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/ObjectifyHelper.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/ObjectifyHelper.java new file mode 100644 index 00000000000..1bd510cde83 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/ObjectifyHelper.java @@ -0,0 +1,37 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.googlecode.objectify.ObjectifyService; + +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +/** + * ObjectifyHelper, a ServletContextListener, is setup in web.xml to run before a JSP is run. This + * is required to let JSP's access Ofy. + **/ +public class ObjectifyHelper implements ServletContextListener { + public void contextInitialized(ServletContextEvent event) { + // This will be invoked as part of a warmup request, or the first user request if no warmup + // request. + ObjectifyService.register(Game.class); + } + + public void contextDestroyed(ServletContextEvent event) { + // App Engine does not currently invoke this method. + } +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/OpenedServlet.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/OpenedServlet.java new file mode 100644 index 00000000000..1553b52da33 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/OpenedServlet.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.googlecode.objectify.NotFoundException; +import com.googlecode.objectify.Objectify; +import com.googlecode.objectify.ObjectifyService; + +import java.io.IOException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Handler that signals to all players of a game that the game has started. + */ +public class OpenedServlet extends HttpServlet { + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + String gameId = request.getParameter("gameKey"); + Objectify ofy = ObjectifyService.ofy(); + try { + Game game = ofy.load().type(Game.class).id(gameId).safe(); + if (gameId != null && request.getUserPrincipal() != null) { + game.sendUpdateToClients(); + response.setContentType("text/plain"); + response.getWriter().println("ok"); + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } catch (NotFoundException e) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + } +} diff --git a/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/TicTacToeServlet.java b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/TicTacToeServlet.java new file mode 100644 index 00000000000..1f9581d9b2e --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/java/com/example/appengine/firetactoe/TicTacToeServlet.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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.example.appengine.firetactoe; + +import com.google.appengine.api.users.UserService; +import com.google.appengine.api.users.UserServiceFactory; +import com.google.gson.Gson; +import com.googlecode.objectify.Objectify; +import com.googlecode.objectify.ObjectifyService; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Base handler for the Tic Tac Toe game. + * This handler serves up the initial jsp page that is the game, and also creates the persistent + * game in the datastore, as well as the Firebase database to serve as the communication channel to + * the clients. + */ +@SuppressWarnings("serial") +public class TicTacToeServlet extends HttpServlet { + + private String getGameUriWithGameParam(HttpServletRequest request, String gameKey) { + try { + String query = ""; + if (gameKey != null) { + query = "gameKey=" + gameKey; + } + URI thisUri = new URI(request.getRequestURL().toString()); + URI uriWithOptionalGameParam = new URI( + thisUri.getScheme(), thisUri.getUserInfo(), thisUri.getHost(), + thisUri.getPort(), thisUri.getPath(), query, ""); + return uriWithOptionalGameParam.toString(); + } catch (URISyntaxException e) { + // This should never happen, since we're constructing the URI from a valid URI. + // Nonetheless, wrap it in a RuntimeException to placate java. + throw new RuntimeException(e); + } + } + + @Override + public void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + final UserService userService = UserServiceFactory.getUserService(); + String gameKey = request.getParameter("gameKey"); + if (userService.getCurrentUser() == null) { + response.getWriter().println("

Please sign in.

"); + return; + } + + // 1. Create or fetch a Game object from the datastore + Objectify ofy = ObjectifyService.ofy(); + Game game = null; + String userId = userService.getCurrentUser().getUserId(); + if (gameKey != null) { + game = ofy.load().type(Game.class).id(gameKey).safe(); + if (game.getUserO() == null && !userId.equals(game.getUserX())) { + game.setUserO(userId); + } + ofy.save().entity(game).now(); + } else { + // Initialize a new board. The board is represented as a String of 9 spaces, one for each + // blank spot on the tic-tac-toe board. + game = new Game(userId, null, " ", true); + ofy.save().entity(game).now(); + gameKey = game.getId(); + } + + // 2. Create this Game in the firebase db + game.sendUpdateToClients(); + + // 3. Inject a secure token into the client, so it can get game updates + + // The 'Game' object exposes a method which creates a unique string based on the game's key + // and the user's id. + String token = FirebaseChannel.getInstance().createFirebaseToken(game, userId); + request.setAttribute("token", token); + + // 4. More general template values + request.setAttribute("game_key", gameKey); + request.setAttribute("me", userId); + request.setAttribute("channel_id", game.getChannelKey(userId)); + request.setAttribute("initial_message", new Gson().toJson(game)); + request.setAttribute("game_link", getGameUriWithGameParam(request, gameKey)); + getServletContext().getRequestDispatcher("/WEB-INF/view/index.jsp").forward(request, response); + } +} diff --git a/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/appengine-web.xml b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 00000000000..60505d89c7a --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,34 @@ + + + + no-longer-required--the-default-gcloud-project-is-used + true + + + + + + + + + + + + + + + diff --git a/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/logging.properties b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/logging.properties new file mode 100644 index 00000000000..f36a2ec76e0 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/logging.properties @@ -0,0 +1,27 @@ +# +# Copyright 2016 Google Inc. All Rights Reserved. +# +# 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. +# +# A default java.util.logging configuration. +# (All App Engine logging is through java.util.logging by default). +# +# To use this configuration, copy it into your application's WEB-INF +# folder and add the following to your appengine-web.xml: +# +# +# +# +# +# Set the default logging level for all loggers to WARNING +.level=WARNING diff --git a/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/firebase_config.jspf b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/firebase_config.jspf new file mode 100644 index 00000000000..25898c985af --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/firebase_config.jspf @@ -0,0 +1,3 @@ +REPLACE ME WITH YOUR FIREBASE WEBAPP CODE SNIPPET: + +https://console.firebase.google.com/project/_/overview diff --git a/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/index.jsp b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/index.jsp new file mode 100644 index 00000000000..c5f08aebf28 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/view/index.jsp @@ -0,0 +1,58 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%-- + Copyright 2016 Google Inc. All Rights Reserved. + + 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. +--%> + + + + <%@ include file="firebase_config.jspf" %> + + + + + + +
+

Firebase-enabled Tic Tac Toe

+
+ Waiting for another player to join.
+ Send them this link to play:
+ +
+
Your move! Click a square to place your piece.
+
Waiting for other player to move...
+
You won this game!
+
You lost this game.
+
+
+
+
+
+
+
+
+
+
+
+
+ Quick link to this game: <%= request.getAttribute("game_link") %> +
+
+ + diff --git a/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/web.xml b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000000..fcc0ee9f00a --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,68 @@ + + + + + index + + + TicTacToeServlet + com.example.appengine.firetactoe.TicTacToeServlet + + + TicTacToeServlet + /index + + + OpenedServlet + com.example.appengine.firetactoe.OpenedServlet + + + OpenedServlet + /opened + + + MoveServlet + com.example.appengine.firetactoe.MoveServlet + + + MoveServlet + /move + + + DeleteServlet + com.example.appengine.firetactoe.DeleteServlet + + + DeleteServlet + /delete + + + + ObjectifyFilter + com.googlecode.objectify.ObjectifyFilter + + + ObjectifyFilter + /* + + + com.example.appengine.firetactoe.ObjectifyHelper + + diff --git a/appengine/firebase-tictactoe/src/main/webapp/static/main.css b/appengine/firebase-tictactoe/src/main/webapp/static/main.css new file mode 100644 index 00000000000..f314eab5b37 --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/static/main.css @@ -0,0 +1,82 @@ +body { + font-family: 'Helvetica'; +} + +#board { + width:152px; + height: 152px; + margin: 20px auto; +} + +#display-area { + text-align: center; +} + +#other-player, #your-move, #their-move, #you-won, #you-lost { + display: none; +} + +#display-area.waiting #other-player { + display: block; +} + +#display-area.waiting #board, #display-area.waiting #this-game { + display: none; +} +#display-area.won #you-won { + display: block; +} +#display-area.lost #you-lost { + display: block; +} +#display-area.your-move #your-move { + display: block; +} +#display-area.their-move #their-move { + display: block; +} + + +#this-game { + font-size: 9pt; +} + +div.cell { + float: left; + width: 50px; + height: 50px; + border: none; + margin: 0px; + padding: 0px; + box-sizing: border-box; + + line-height: 50px; + font-family: "Helvetica"; + font-size: 16pt; + text-align: center; +} + +.your-move div.cell:hover { + background: lightgrey; +} + +.your-move div.cell:empty:hover { + background: lightblue; + cursor: pointer; +} + +div.l { + border-right: 1pt solid black; +} + +div.r { + border-left: 1pt solid black; +} + +div.t { + border-bottom: 1pt solid black; +} + +div.b { + border-top: 1pt solid black; +} diff --git a/appengine/firebase-tictactoe/src/main/webapp/static/main.js b/appengine/firebase-tictactoe/src/main/webapp/static/main.js new file mode 100644 index 00000000000..804b74f86be --- /dev/null +++ b/appengine/firebase-tictactoe/src/main/webapp/static/main.js @@ -0,0 +1,174 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * 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. + */ + +'use strict'; + +/** + * @fileoverview Tic-Tac-Toe, using the Firebase API + */ + +/** + * @param gameKey - a unique key for this game. + * @param me - my user id. + * @param token - secure token passed from the server + * @param channelId - id of the 'channel' we'll be listening to + */ +function initGame(gameKey, me, token, channelId, initialMessage) { + var state = { + gameKey: gameKey, + me: me + }; + + // This is our Firebase realtime DB path that we'll listen to for updates + // We'll initialize this later in openChannel() + var channel = null; + + /** + * Updates the displayed game board. + */ + function updateGame(newState) { + $.extend(state, newState); + + $('.cell').each(function(i) { + var square = $(this); + var value = state.board[i]; + square.html(' ' === value ? '' : value); + + if (state.winner && state.winningBoard) { + if (state.winningBoard[i] === value) { + if (state.winner === state.me) { + square.css('background', 'green'); + } else { + square.css('background', 'red'); + } + } else { + square.css('background', ''); + } + } + }); + + var displayArea = $('#display-area'); + + if (!state.userO) { + displayArea[0].className = 'waiting'; + } else if (state.winner === state.me) { + displayArea[0].className = 'won'; + } else if (state.winner) { + displayArea[0].className = 'lost'; + } else if (isMyMove()) { + displayArea[0].className = 'your-move'; + } else { + displayArea[0].className = 'their-move'; + } + } + + function isMyMove() { + return !state.winner && (state.moveX === (state.userX === state.me)); + } + + function myPiece() { + return state.userX === state.me ? 'X' : 'O'; + } + + /** + * Send the user's latest move back to the server + */ + function moveInSquare(e) { + var id = $(e.currentTarget).index(); + if (isMyMove() && state.board[id] === ' ') { + $.post('/move', {cell: id}); + } + } + + /** + * This method lets the server know that the user has opened the channel + * After this method is called, the server may begin to send updates + */ + function onOpened() { + $.post('/opened'); + } + + /** + * This deletes the data associated with the Firebase path + * it is critical that this data be deleted since it costs money + */ + function deleteChannel() { + $.post('/delete'); + } + + /** + * This method is called every time an event is fired from Firebase + * it updates the entire game state and checks for a winner + * if a player has won the game, this function calls the server to delete + * the data stored in Firebase + */ + function onMessage(newState) { + updateGame(newState); + + // now check to see if there is a winner + if (channel && state.winner && state.winningBoard) { + channel.off(); //stop listening on this path + deleteChannel(); //delete the data we wrote + } + } + + /** + * This function opens a realtime communication channel with Firebase + * It logs in securely using the client token passed from the server + * then it sets up a listener on the proper database path (also passed by server) + * finally, it calls onOpened() to let the server know it is ready to receive messages + */ + function openChannel() { + // sign into Firebase with the token passed from the server + firebase.auth().signInWithCustomToken(token).catch(function(error) { + console.log('Login Failed!', error.code); + console.log('Error message: ', error.message); + }); + + // setup a database reference at path /channels/channelId + channel = firebase.database().ref('channels/' + channelId); + // add a listener to the path that fires any time the value of the data changes + channel.on('value', function(data) { + onMessage(data.val()); + }); + onOpened(); + // let the server know that the channel is open + } + + /** + * This function opens a communication channel with the server + * then it adds listeners to all the squares on the board + * next it pulls down the initial game state from template values + * finally it updates the game state with those values by calling onMessage() + */ + function initialize() { + // Always include the gamekey in our requests + $.ajaxPrefilter(function(opts) { + if (opts.url.indexOf('?') > 0) + opts.url += '&gameKey=' + state.gameKey; + else + opts.url += '?gameKey=' + state.gameKey; + }); + + $('#board').on('click', '.cell', moveInSquare); + + openChannel(); + + onMessage(initialMessage); + } + + setTimeout(initialize, 100); +}