/** * (C) 2015 NPException */ package nl.lang2619.bagginses.gameanalytics; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.security.MessageDigest; import java.util.*; import java.util.concurrent.Semaphore; import javax.xml.bind.DatatypeConverter; import com.google.gson.Gson; import nl.lang2619.bagginses.gameanalytics.events.GAErrorEvent; import nl.lang2619.bagginses.gameanalytics.events.GAEvent; import nl.lang2619.bagginses.gameanalytics.util.ACLock; /** * @author NPException * */ final class EventHandler { private static boolean init = true; private static final Queue<GAEvent> immediateEvents = new ArrayDeque<>(32); private static Thread sendImmediateThread; private static ACLock immediateEvents_lock = new ACLock(true); private static Semaphore sendSemaphore = new Semaphore(0); private static ACLock getEventsForGame_lock = new ACLock(true); private static ACLock getCategoryEvents_lock = new ACLock(true); private static ACLock sendData_lock = new ACLock(true); private static ACLock errorSend_lock = new ACLock(true); /** * Map containing all not yet sent events.<br> * <br> * Map: KeyPair -> Map: category -> event list */ private static final Map<Analytics.KeyPair, Map<String, List<GAEvent>>> events = new HashMap<>(8); private static Map<String, List<GAEvent>> getEventsForGame(Analytics.KeyPair keyPair) { try (ACLock acl = getEventsForGame_lock.lockAC()) { Map<String, List<GAEvent>> gameEvents = events.get(keyPair); if (gameEvents == null) { gameEvents = new HashMap<>(); events.put(keyPair, gameEvents); } return gameEvents; } } private static List<GAEvent> getCategoryEvents(Map<String, List<GAEvent>> gameEvents, String category) { try (ACLock acl = getCategoryEvents_lock.lockAC()) { List<GAEvent> categoryEvents = gameEvents.get(category); if (categoryEvents == null) { categoryEvents = new ArrayList<>(16); gameEvents.put(category, categoryEvents); } return categoryEvents; } } static void add(GAEvent event) { try { Map<String, List<GAEvent>> gameEvents = getEventsForGame(event.keyPair); List<GAEvent> categoryEvents = getCategoryEvents(gameEvents, event.category()); synchronized (categoryEvents) { categoryEvents.add(event); } } catch (Exception ex) { System.err.println("Failed to add GAEvent to event queue: " + event); ex.printStackTrace(System.err); } init(); } static void queueImmediateSend(GAEvent event) { boolean added = false; try (ACLock acl = immediateEvents_lock.lockAC()) { added = immediateEvents.offer(event); } if (added) { sendSemaphore.release(); // increase free permits on semaphore by 1 } else { System.err.println("Could not add event to immediate events queue: " + event); } init(); } static void sendErrorNow(final GAErrorEvent event, boolean useThread) { if (useThread) { Thread errorSendThread = new Thread("GA-send-error-now") { @Override public void run() { try (ACLock acl = errorSend_lock.lockAC()) { RESTHelper.sendSingleEvent(event); } } }; errorSendThread.start(); } else { try (ACLock acl = errorSend_lock.lockAC()) { RESTHelper.sendSingleEvent(event); } } } private static void init() { if (!init) return; synchronized (EventHandler.class) { if (!init) return; init = false; } final int sleepTime = APIProps.PUSH_INTERVAL_SECONDS * 1000; Thread sendThread = new Thread("GA-DataSendThread") { @Override public void run() { while (true) { try { sleep(sleepTime); sendData(); } catch (Exception e) { e.printStackTrace(); } } } }; sendThread.setDaemon(true); sendThread.start(); sendImmediateThread = new Thread("GA-DataSendImmediatelyThread") { @Override public void run() { while (true) { sendSemaphore.acquireUninterruptibly(); // try to aquire a permit. will only happen if something is in the queue GAEvent event; try (ACLock acl = immediateEvents_lock.lockAC()) { event = immediateEvents.poll(); } if (event != null) { RESTHelper.sendSingleEvent(event); } else { System.err.println("Immediate event queue did not contain an event. Something released a permit without adding an event first."); } } } }; sendImmediateThread.setDaemon(true); sendImmediateThread.start(); } private static void sendData() { try (ACLock acl = sendData_lock.lockAC()) { Set<Analytics.KeyPair> keyPairs = events.keySet(); for (Analytics.KeyPair keyPair : keyPairs) { Map<String, List<GAEvent>> gameEvents = getEventsForGame(keyPair); if (gameEvents.isEmpty()) { continue; } List<String> categories = new ArrayList<>(gameEvents.keySet()); for (String category : categories) { // category already exists so we don't need to use the synchronized method. List<GAEvent> categoryEvents = gameEvents.get(category); List<GAEvent> categoryEventsCopy; synchronized (categoryEvents) { if (categoryEvents.isEmpty()) { continue; } categoryEventsCopy = new ArrayList<>(categoryEvents); categoryEvents.clear(); } RESTHelper.sendData(keyPair, category, categoryEventsCopy); } } } } private static class RESTHelper { private RESTHelper() {} private static final Gson gson = new Gson(); private static final String contentType = "application/json; charset=utf-8"; private static final String accept = "application/json"; static void sendSingleEvent(GAEvent event) { try { sendData(event.keyPair, event.category(), Arrays.asList(event)); } catch (Exception e) { // System.err.println("Tried to send single event, but failed."); } } static void sendData(Analytics.KeyPair keyPair, String category, List<GAEvent> events) { String[] result = sendAndGetResponse(keyPair, category, events); String status = result[0]; // While we expect JSON here, GA does not seem to care about the requested response // type all the time. That's why we check for plaintext "ok" as well. if (!"{\"status\":\"ok\"}".equals(status) && !"ok".equals(status)) { System.err.println("Failed to send analytics event data. Result of attempt: " + status + " | Authentication hash used: " + result[1] + " | Data sent: " + result[2]); } } /** * Sends the events to GA and returns a String array with the following * contents:<br> * <ul> * <li>Index 0: The response from GA, or exception message if one was * thrown.</li> * <li>Index 1: The authentication hash used to sent the data to GA, or * "null" if an exception was thrown.</li> * <li>Index 2: The json data that was sent to GA, or "null" if an * exception was thrown.</li> * </ul> */ private static String[] sendAndGetResponse(Analytics.KeyPair keyPair, String category, List<GAEvent> events) { try { String postData = gson.toJson(events); byte[] postBytes = postData.getBytes("UTF-8"); byte[] authData = (postData + keyPair.secretKey).getBytes("UTF-8"); String hashedAuthData = DatatypeConverter.printHexBinary(MessageDigest.getInstance("MD5").digest(authData)).toLowerCase(); URL url = new URL(APIProps.GA_API_URL + APIProps.GA_API_VERSION + "/" + keyPair.gameKey + "/" + category); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setDoOutput(true); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", hashedAuthData); connection.setRequestProperty("Accept", accept); connection.setRequestProperty("Content-Type", contentType); connection.setRequestProperty("Content-Length", String.valueOf(postBytes.length)); StringBuilder responseSB = new StringBuilder(); try (OutputStream os = connection.getOutputStream()) { // Write data os.write(postBytes); // Read response try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"))) { String line; while ((line = br.readLine()) != null) { responseSB.append(line); } } } return new String[] { responseSB.toString(), hashedAuthData, postData }; } catch (Exception ex) { return new String[] { ex.getMessage(), "null", "null" }; } } } }