Skip to content

Conversation

@jerjou
Copy link
Contributor

@jerjou jerjou commented Oct 11, 2016

This is my first pass at this. I copied the channel api sample to start, so there might be some vestigial stuff from that. I'm going to make another clean-up pass on this, but I figured I'd send it out sooner rather than later so you can start taking a look.

FYI this is on GAE standard, just 'cuz the channel api was on standard. Think it's worth it to put it on flex?

@googlebot googlebot added the cla: yes This human has signed the Contributor License Agreement. label Oct 11, 2016
@codecov-io
Copy link

codecov-io commented Oct 11, 2016

Current coverage is 48.19% (diff: 100%)

Merging #358 into master will not change coverage

@@             master       #358   diff @@
==========================================
  Files            73         73          
  Lines          2268       2268          
  Methods           0          0          
  Messages          0          0          
  Branches        158        158          
==========================================
  Hits           1093       1093          
  Misses         1145       1145          
  Partials         30         30          

Powered by Codecov. Last update 9474200...e9ed5e2

Copy link
Contributor

@lesv lesv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good start.

  1. Why use both Objectify and Firebase?
  2. Should use new maven plugin.
  3. Could use a few comments everywhere - esp. where api's are used.
  4. Either do something on exceptions or don't catch them.
  5. Why use old nomenclature? channelId?

<relativePath>../..</relativePath>
</parent>
<properties>
<objectify.version>5.1.13</objectify.version>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I never really got the point of specifying the version numbers as a separate property, though, so if you don't know either, I can just specify it as a literal in the dependency section ;)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No the repo-gardener requires it to be in properties, so thats a good thing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, for whatever reason the versions plugin can detect version upgrades for plugins, but it can only update properties and dependency versions, not plugin versions directly.

<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.json</groupId>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - communicating with firebase is all through json

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I typically use this as it's very easy to use, but consider Google GSON. I'm trying to switch.

<version>20160810</version>
</dependency>
<dependency>
<groupId>com.googlecode.objectify</groupId>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup - in this app, datastore is the canonical representation of the game board, and we use objectify to persist it.

<plugins>
<!-- Parent POM defines ${appengine.sdk.version} (updates frequently). -->
<plugin>
<groupId>com.google.appengine</groupId>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old plugin, please use the new one.

Copy link
Contributor Author

@jerjou jerjou Oct 11, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh... oddly enough, switching to the new plugin causes firebase calls to fail..
Updating for now. Working on getting it to work with the new plugin..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GoogleCloudPlatform/cloud-tools-for-java FYI

public void doPost(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
UserService userService = UserServiceFactory.getUserService();
String gameId = req.getParameter("g");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"g" -- shouldn't it be a bit longer?

Copy link
Contributor Author

@jerjou jerjou Oct 11, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno - this is what the python query parameter was. The channel sample alternated between calling it "g" and "gameKey".

shrug Updated to be more descriptive.

var config = {
apiKey: "AIzaSyC3gJDK1r-8nZuJGdmXntJR4E8cqH8qSTk",
authDomain: "channels-api-deprecation.firebaseapp.com",
databaseURL: "https://channels-api-deprecation.firebaseio.com",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

apiKey: "AIzaSyC3gJDK1r-8nZuJGdmXntJR4E8cqH8qSTk",
authDomain: "channels-api-deprecation.firebaseapp.com",
databaseURL: "https://channels-api-deprecation.firebaseio.com",
storageBucket: "channels-api-deprecation.appspot.com",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

authDomain: "channels-api-deprecation.firebaseapp.com",
databaseURL: "https://channels-api-deprecation.firebaseio.com",
storageBucket: "channels-api-deprecation.appspot.com",
messagingSenderId: "542117602304"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

limitations under the License.
-->
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
<application>YOUR-PROJECTID-HERE</application>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowercase will work better w/ new plugin.

-->
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
<application>YOUR-PROJECTID-HERE</application>
<version>1</version>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer required.

Copy link
Contributor Author

@jerjou jerjou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay - I ran into a problem when actually deploying the app (plus the whole new-maven-plugin not working, which might be related), so working on that. But for the moment, I've pushed some updates to this PR.

<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.json</groupId>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - communicating with firebase is all through json

<relativePath>../..</relativePath>
</parent>
<properties>
<objectify.version>5.1.13</objectify.version>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I never really got the point of specifying the version numbers as a separate property, though, so if you don't know either, I can just specify it as a literal in the dependency section ;)

<version>20160810</version>
</dependency>
<dependency>
<groupId>com.googlecode.objectify</groupId>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup - in this app, datastore is the canonical representation of the game board, and we use objectify to persist it.

<plugins>
<!-- Parent POM defines ${appengine.sdk.version} (updates frequently). -->
<plugin>
<groupId>com.google.appengine</groupId>
Copy link
Contributor Author

@jerjou jerjou Oct 11, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh... oddly enough, switching to the new plugin causes firebase calls to fail..
Updating for now. Working on getting it to work with the new plugin..

public void doPost(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
UserService userService = UserServiceFactory.getUserService();
String gameId = req.getParameter("g");
Copy link
Contributor Author

@jerjou jerjou Oct 11, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno - this is what the python query parameter was. The channel sample alternated between calling it "g" and "gameKey".

shrug Updated to be more descriptive.


String currentUserId = userService.getCurrentUser().getUserId();
if (currentUserId.equals(game.getUserX()) || currentUserId.equals(game.getUserO())) {
/*
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah - I just commented this out in anticipation of filling it in later, but turns out this servlet isn't actually referenced anywhere so I'll just delete it.

throws IOException {
UserService userService = UserServiceFactory.getUserService();
String gameId = req.getParameter("g");
int piece = new Integer(req.getParameter("i"));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to be more descriptive

// 4. More general template values
req.setAttribute("game_key", gameKey);
req.setAttribute("me", userId);
req.setAttribute("channel_id", channelId);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup - the "channel id" is now a unique identifier on the firebase side that the server posts updates to, and the client listens to for updates.

<script>
// Initialize Firebase
var config = {
apiKey: "AIzaSyC3gJDK1r-8nZuJGdmXntJR4E8cqH8qSTk",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh whoops. Yes.

// Initialize Firebase
var config = {
apiKey: "AIzaSyC3gJDK1r-8nZuJGdmXntJR4E8cqH8qSTk",
authDomain: "channels-api-deprecation.firebaseapp.com",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a README with setup steps.


* If you see the error `Google Cloud SDK path was not provided ...`:
* Make sure you've installed the [Google cloud SDK][sdk]
* You may have installed it in a non-standard path. In that case, set the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - you might mention that the Google Cloud SDK needs to be on your PATH, if not, you can fix it by adding...

<relativePath>../..</relativePath>
</parent>
<properties>
<objectify.version>5.1.13</objectify.version>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No the repo-gardener requires it to be in properties, so thats a good thing.

<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.json</groupId>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I typically use this as it's very easy to use, but consider Google GSON. I'm trying to switch.

return firebaseSnippet.substring(openQuote + 1, closeQuote);
}

Game() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might wish to set an ID here.

// 4. More general template values
req.setAttribute("game_key", gameKey);
req.setAttribute("me", userId);
req.setAttribute("channel_id", channelId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be renamed?

# 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, using the [Firebase] database
Copy link
Contributor

@elharo elharo Oct 12, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd put Standard inside the link and link to https://cloud.google.com/appengine/docs/about-the-standard-environment instead


## Prerequisites

* Install [Apache Maven][maven]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3.3.9 or later

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It used to be the case for the old appengine plugin, but I've been using mvn 3.0.5 with our plugin and it hasn't complained.

* Install [Apache Maven][maven]
* Create a project in the [Firebase Console][fb-console]
* Install the [Google Cloud SDK][sdk]
* Download [service account credentials][creds] and move it to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move them


public class DeleteServlet extends HttpServlet {
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req --> request
resp --> response
since Google Java style avoids abbreviatiosn

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevRel is a bit more relaxed on this.

static {
try {
sFirebaseSnippet = CharStreams.toString(
new InputStreamReader(new FileInputStream(FIREBASE_SNIPPET_PATH)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs character encoding specified

.setDatabaseUrl(sFirebaseDbUrl)
.build();
FirebaseApp.initializeApp(options);
} catch (Exception e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to catch a more specfic exception if possible

* OfyHelper, 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 OfyHelper implements ServletContextListener {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectifyHelper

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class MoveServlet extends HttpServlet {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Javadoc for this class?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least some comments, if not full JavaDoc

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class OpenedServlet extends HttpServlet {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc for this class?

@jerjou
Copy link
Contributor Author

jerjou commented Oct 13, 2016

Pushed another iteration. Not all the comments were addressed yet - of note, I haven't figured out how to use Guice yet -_-;

@lesv
Copy link
Contributor

lesv commented Oct 13, 2016

Take a look at the Objectify Guestbook in flexible/

@tswast
Copy link
Contributor

tswast commented Oct 13, 2016

I'd recommend Dagger 2 over Guice. Guice is runtime dependency injection and dagger is compile-time (plus a lot easier to debug)

@lesv
Copy link
Contributor

lesv commented Oct 13, 2016

Sigh - meant to review this AM.

Copy link
Contributor

@lesv lesv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PTAL

* Install [Apache Maven][maven] 3.0.5 or later
* Create a project in the [Firebase Console][fb-console]
* Install the [Google Cloud SDK][sdk]
* For staging locally, you must supply credentials that would otherwise be
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"staging locally" -> "local development". gcloud auth application-default login is preferred to GOOGLE_APPLICATION_CREDENTIALS

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gcloud auth application-default login doesn't work (again). I'm getting permission-denied when using it to make firebase calls from the server. I think it also doesn't provide a value for appIdentity.getServiceAccountName(), since it's not a service account. And I think it also can't sign the jwt, since it's not actually an RSA cert.

I changed it from "local development" to "staging locally" because I was afraid "local development" would be confused with like, just coding it locally (and deploying it remotely). What about - "If using the dev appserver, ..."?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - that makes sense - so explain that. (It's a firebase "feature")

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.
* Edit
[`src/main/webapp/WEB-INF/appengine-web.xml`](src/main/webapp/WEB-INF/appengine-web.xml)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really isn't required anymore, I typically use the string no-longer-required, the next Java SDK release will let us delete the line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - so it fails if the element isn't there, but then it ignores it. Yay.

<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>appengine-maven-plugin</artifactId>
<version>1.0.0</version>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Us a property variable here.


public class DeleteServlet extends HttpServlet {
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevRel is a bit more relaxed on this.

import java.util.HashMap;
import java.util.Map;

public class FirebaseChannel {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments about this class? Why is it here? You might also want to include our // [START blah] tags for interesting parts.

return instance;
}

private FirebaseChannel() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JavaDoc or at least some comments -- we are trying to teach.

return message.toString();
}

//[START send_updates]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments would be good, if you are expecting us to publish it.

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class MoveServlet extends HttpServlet {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least some comments, if not full JavaDoc

thisUri.getScheme(), thisUri.getUserInfo(), thisUri.getHost(),
thisUri.getPort(), thisUri.getPath(), query, "");
return uriWithOptionalGameParam.toString();
} catch (URISyntaxException e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we aren't bothering to do something with it, why catch it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the very least tell the user why they want to catch it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm... because java won't compile it unless I do? :-) It's either that, or let it through and make all the callers deal with it...

Copy link
Contributor

@lesv lesv Oct 13, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can mention that your method throws URISyntaxEception at the top and it should compile.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, it doesn't look like URISyntaxException is a RuntimeException, so all callers would have to deal with it :-/

}
ofy.save().entity(game).now();
} else {
game = new Game(userId, null, " ", true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All those spaces seem wrong for some reason, seems pointless to have more than one, HTML will compress it, who needs that many?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yes, I'm sure it came to you that way, so, I'm find if you just understand why that's necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha :-) The spaces are a flat representation of the game board. ie there are nine blank spaces, to signify that each of the nine spaces of the tic-tac-toe board are empty and available to move to. As the game progresses, they'll be replaced by 'X' and 'O' characters.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though you could mention that in a comment.

Copy link
Contributor Author

@jerjou jerjou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated - PTAL

// 4. More general template values
req.setAttribute("game_key", gameKey);
req.setAttribute("me", userId);
req.setAttribute("channel_id", game.getChannelKey(userId));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "channel_id" still conveys the meaning fairly well. It's still a communication channel, even if it's not using the channel api per se.

I dunno - do you have a strong preference for another name?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

* Install [Apache Maven][maven] 3.0.5 or later
* Create a project in the [Firebase Console][fb-console]
* Install the [Google Cloud SDK][sdk]
* For staging locally, you must supply credentials that would otherwise be
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gcloud auth application-default login doesn't work (again). I'm getting permission-denied when using it to make firebase calls from the server. I think it also doesn't provide a value for appIdentity.getServiceAccountName(), since it's not a service account. And I think it also can't sign the jwt, since it's not actually an RSA cert.

I changed it from "local development" to "staging locally" because I was afraid "local development" would be confused with like, just coding it locally (and deploying it remotely). What about - "If using the dev appserver, ..."?

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.
* Edit
[`src/main/webapp/WEB-INF/appengine-web.xml`](src/main/webapp/WEB-INF/appengine-web.xml)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - so it fails if the element isn't there, but then it ignores it. Yay.

thisUri.getScheme(), thisUri.getUserInfo(), thisUri.getHost(),
thisUri.getPort(), thisUri.getPath(), query, "");
return uriWithOptionalGameParam.toString();
} catch (URISyntaxException e) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm... because java won't compile it unless I do? :-) It's either that, or let it through and make all the callers deal with it...

}
ofy.save().entity(game).now();
} else {
game = new Game(userId, null, " ", true);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha :-) The spaces are a flat representation of the game board. ie there are nine blank spaces, to signify that each of the nine spaces of the tic-tac-toe board are empty and available to move to. As the game progresses, they'll be replaced by 'X' and 'O' characters.

@jerjou jerjou force-pushed the channel-api branch 2 times, most recently from ce7de41 to 36c256b Compare October 13, 2016 21:43
@lesv
Copy link
Contributor

lesv commented Oct 14, 2016

LGTM

@jerjou jerjou merged commit 6444d05 into master Oct 14, 2016
@jerjou jerjou deleted the channel-api branch October 14, 2016 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: yes This human has signed the Contributor License Agreement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants