package models.services; import akka.actor.ActorRef; import akka.actor.Props; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import managers.AccountManager; import managers.FriendshipManager; import models.Account; import models.actors.WebSocketActor; import play.Logger; import play.db.jpa.JPAApi; import play.libs.Akka; import play.libs.Json; import play.mvc.WebSocket; import javax.inject.Inject; import java.lang.reflect.Method; import java.util.Date; import java.util.HashMap; import java.util.Map; /** * WebSocket service that is handling all active WebSocket actors. */ @SuppressWarnings("unused") public class WebSocketService { public static final String WS_METHOD_SEND_CHAT = "SendChat"; public static final String WS_METHOD_RECEIVE_CHAT = "ReceiveChat"; public static final String WS_METHOD_RECEIVE_NOTIFICATION = "ReceiveNotification"; public static final String WS_METHOD_RECEIVE_PING = "Ping"; public static final String WS_RESPONSE_OK = "OK"; public static final String WS_RESPONSE_ERROR = "ERROR"; @Inject AccountManager accountManager; @Inject FriendshipManager friendshipManager; @Inject JPAApi jpaApi; /** * Singleton instance */ private static WebSocketService instance = null; /** * Holds all active WebSocket actors per Account ID */ Map<Long, ActorRef> accountActor = new HashMap<>(); /** * Returns the singleton instance. * * @return NotificationHandler instance */ public static WebSocketService getInstance() { if (WebSocketService.instance == null) { WebSocketService.instance = new WebSocketService(); } return WebSocketService.instance; } /** * Invokes a new ActorRef instance of type WebSocketActor for an account ID. * * @param account Account * @param in WebSocket input stream * @param out WebSocket output stream */ public void invokeActor(final Account account, WebSocket.In<JsonNode> in, WebSocket.Out<JsonNode> out) { if (this.getActorForAccount(account) != null) { return; } this.accountActor.put(account.id, Akka.system().actorOf(Props.create(WebSocketActor.class, account, in, out))); } /** * Returns an ActorRef instance for an account if available, otherwise null. * * @param account Account * @return ActorRef instance if available for account ID, otherwise null */ public ActorRef getActorForAccount(Account account) { return this.getActorForAccountId(account.id); } /** * Returns an ActorRef instance for an account ID if available, otherwise null. * * @param accountId Account ID * @return ActorRef instance if available for account ID, otherwise null */ public ActorRef getActorForAccountId(Long accountId) { if (!this.accountActor.containsKey(accountId)) { return null; } return this.accountActor.get(accountId); } /** * Returns an ActorRef instance for an account ID. If no ActorRef available the invokeActor() method * is called and the new ActorRef instance is returned. * * @param account Account * @param in WebSocket input stream * @param out WebSocket output stream * @return ActorRef instance */ public ActorRef getActorForAccount(Account account, WebSocket.In<JsonNode> in, WebSocket.Out<JsonNode> out) { if (this.getActorForAccount(account) == null) { this.invokeActor(account, in, out); } return this.getActorForAccount(account); } /** * Stops an ActorRef for an account ID. * * @param account Account */ public void stopActor(Account account) { if (this.getActorForAccount(account) == null) { return; } ActorRef stoppingActorRef = this.accountActor.remove(account.id); Akka.system().stop(stoppingActorRef); } /** * Handles a WebSocket message * * @param account Account * @param wsMessage WebSocket message as JsonNode object */ public void handleWsMessage(Account account, JsonNode wsMessage) { // Log events to the console Logger.info("[WS] Received (User ID: " + account.id + "): " + wsMessage.toString()); if (!wsMessage.has("method")) { Logger.error("[WS] No method received"); return; } ActorRef senderActor = this.getActorForAccount(account); senderActor.tell(wsMessage, null); } /** * Tries to invoke a WebSocket method. * * @param wsMessage WebSocket message as JsonNode object * @param senderActor Sending actor * @param sender Sending account * @return WebSocket response */ public JsonNode invokeWsMethod(JsonNode wsMessage, ActorRef senderActor, Account sender) { String methodName = "ws" + wsMessage.get("method").asText(); Class[] classParameters = new Class[] { JsonNode.class, ActorRef.class, Account.class }; Object[] invocationParameters = new Object[] { wsMessage, senderActor, sender }; try { // retrieve callable method and set accessible to true, as the methods are not declared // as public, finally invoke the method Method wsMethod = this.getClass().getDeclaredMethod(methodName, classParameters); wsMethod.setAccessible(true); return (JsonNode)wsMethod.invoke(this, invocationParameters); } catch (Exception e) { return this.errorResponse("Undefined error"); } } /** * Returns a WebSocket error. * * @param errorMessage Error text message * @return WebSocket response */ private JsonNode errorResponse(String errorMessage) { ObjectNode node = Json.newObject(); node.put("code", WebSocketService.WS_RESPONSE_ERROR); node.put("text", errorMessage); return Json.toJson(node); } /** * Returns a success response template as ObjectNode instance with mandatory meta data. * * @param method WebSocket method as String * @return ObjectNode instance */ public ObjectNode successResponseTemplate(String method) { Map<String, Object> map = new HashMap<>(); map.put("method", method); map.put("code", WebSocketService.WS_RESPONSE_OK); map.put("time", new Date()); return JsonService.getInstance().getObjectNodeFromMap(map); } /** * Returns an account by ID, enclosed by transaction. * * @param accountId Account ID * @return Account */ private Account getAccountById(final Long accountId) { try { return jpaApi.withTransaction(() -> { return accountManager.findById(accountId); }); } catch (Throwable throwable) { throwable.printStackTrace(); return null; } } /** * Returns true, if Account A and Account B are friends. * * @param a Account A * @param b Account B * @return True, if friendship is established */ private boolean isFriendshipEstablished(final Account a, final Account b) { try { return jpaApi.withTransaction(() -> { return friendshipManager.alreadyFriendly(a, b); }); } catch (Throwable throwable) { throwable.printStackTrace(); return false; } } /** * WebSocket method Ping for testing purposes. * * @param wsMessage WebSocket message as JsonNode object * @param senderActor Sending actor * @param sender Sending account * @return WebSocket response */ private JsonNode wsPing(JsonNode wsMessage, ActorRef senderActor, Account sender) { return Json.toJson("Pong"); } /** * WebSocket method when sending chat. * * @param wsMessage WebSocket message as JsonNode object * @param senderActor Sending actor * @param sender Sending account * @return WebSocket response */ private JsonNode wsSendChat(JsonNode wsMessage, ActorRef senderActor, Account sender) { // validate given parameters if (!wsMessage.has("recipient") || !wsMessage.has("text")) { return this.errorResponse("Could not send chat message, either no \"recipient\" or \"text\" information."); } Long recipientAccountId = wsMessage.get("recipient").asLong(); String text = wsMessage.get("text").asText(); Account recipient = this.getAccountById(recipientAccountId); // check, if recipient exists if (recipient == null) { return this.errorResponse("User not found"); } ActorRef recipientActor = this.getActorForAccount(recipient); // check, if the recipient is online if (recipientActor == null) { return this.errorResponse("Recipient not online"); } // sender should not be recipient if (recipientActor.equals(senderActor)) { return this.errorResponse("Cannot send chat to yourself"); } // check, if sender and recipient are friends if (sender == null || !this.isFriendshipEstablished(sender, recipient)) { return this.errorResponse("You must be a friend of the recipient"); } ObjectNode node = this.successResponseTemplate(WebSocketService.WS_METHOD_RECEIVE_CHAT); node.put("sender", Json.toJson(sender.getAsJson())); node.put("text", text); recipientActor.tell(Json.toJson(node), senderActor); return Json.toJson("OK"); } /** * WebSocket method when receiving chat. * * @param wsMessage WebSocket message as JsonNode object * @param senderActor Sending actor * @param sender Sending account * @return WebSocket response */ private JsonNode wsReceiveChat(JsonNode wsMessage, ActorRef senderActor, Account sender) { return wsMessage; } /** * WebSocket method when receiving notification. * * @param wsMessage WebSocket message as JsonNode object * @param senderActor Sending actor * @param sender Sending account * @return WebSocket response */ private JsonNode wsReceiveNotification(JsonNode wsMessage, ActorRef senderActor, Account sender) { return wsMessage; } }