/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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 org.apache.usergrid.services.notifications.gcm; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.android.gcm.server.*; import org.apache.usergrid.persistence.entities.Notification; import org.apache.usergrid.persistence.entities.Notifier; import org.apache.usergrid.services.notifications.InactiveDeviceManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.usergrid.services.ServicePayload; import org.apache.usergrid.persistence.EntityManager; import org.apache.usergrid.persistence.exceptions.RequiredPropertyNotFoundException; import org.apache.usergrid.services.notifications.ConnectionException; import org.apache.usergrid.services.notifications.ProviderAdapter; import org.apache.usergrid.services.notifications.TaskTracker; import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class GCMAdapter implements ProviderAdapter { private static final Logger logger = LoggerFactory.getLogger(GCMAdapter.class); private static final int SEND_RETRIES = 3; private static int BATCH_SIZE = 1000; private final Notifier notifier; private EntityManager entityManager; private static ObjectMapper objectMapper = new ObjectMapper(); private ConcurrentHashMap<Long,Batch> batches; private static final String ttlKey = "time_to_live"; private static final String priorityKey = "priority"; private static final String dataKey = "data"; public GCMAdapter(EntityManager entityManager,Notifier notifier){ this.notifier = notifier; this.entityManager = entityManager; batches = new ConcurrentHashMap<>(); } @Override public void testConnection() throws Exception { Sender sender = new Sender(notifier.getApiKey()); Message message = new Message.Builder().addData("registration_id", "").build(); List<String> ids = new ArrayList<>(); ids.add("device_token"); try { MulticastResult result = sender.send(message, ids, 1); if (logger.isTraceEnabled()) { logger.trace("testConnection result: {}", result); } } catch (InvalidRequestException e){ // do nothing, we don't have a valid device token to test with if (logger.isTraceEnabled()) { logger.trace("no valid device token"); } } catch (IOException e) { if(isInvalidRequestException(e)){ throw new InvalidRequestException(401, Constants.ERROR_INVALID_REGISTRATION); }else { throw new ConnectionException(e.getMessage(), e); } } } @Override public void sendNotification(String providerId, Object payload, Notification notification, TaskTracker tracker) throws Exception { Map<String,Object> map = (Map<String, Object>) payload; if(!map.containsKey(ttlKey) && notification.getExpire() != null){ // ttl provided to GCM is in seconds. calculate the difference from now long ttlSeconds = notification.getExpireTTLSeconds(); // max ttl for gcm is 4 weeks - https://developers.google.com/cloud-messaging/http-server-ref ttlSeconds = ttlSeconds <= 2419200 ? ttlSeconds : 2419200; map.put(ttlKey, (int)ttlSeconds);//needs to be int } if(!map.containsKey(priorityKey) && notification.getPriority() != null){ map.put(priorityKey, notification.getPriority()); } Batch batch = getBatch( map ); batch.add(providerId, tracker); } private Batch getBatch( Map<String, Object> payload) { synchronized (this) { Batch batch = new Batch(notifier,null); if( payload != null ) { long hash = payload.hashCode(); // assume there won't be collisions in our amount of concurrency batch = batches.get(hash); if (batch == null) { batch = new Batch(notifier, payload); batches.put(hash, batch); } } return batch; } } @Override public void doneSendingNotifications() throws Exception { synchronized (this) { for (Batch batch : batches.values()) { batch.send(); } } } @Override public void removeInactiveDevices( ) throws Exception { Batch batch = getBatch( null); if(batch != null) { Map<String,Date> map = batch.getAndClearInactiveDevices(); InactiveDeviceManager deviceManager = new InactiveDeviceManager(notifier,entityManager); deviceManager.removeInactiveDevices(map); } } @Override public Map<String, Object> translatePayload(Object payload) throws Exception { Map<String, Object> mapPayload = new HashMap<String, Object>(); if (payload instanceof Map) { mapPayload = (Map<String, Object>) payload; } else if (payload instanceof String) { mapPayload.put(dataKey, payload); } else { throw new IllegalArgumentException( "GCM Payload must be either a Map or a String"); } String payloadString = objectMapper.writeValueAsString( mapPayload ); if ( payloadString.length() > 4096) { throw new IllegalArgumentException( "GCM payloads must be 4096 characters or less"); } return mapPayload; } @Override public void validateCreateNotifier(ServicePayload payload) throws Exception { if (payload.getProperty("apiKey") == null) { throw new RequiredPropertyNotFoundException("notifier", "apiKey"); } } @Override public void stop() { try { synchronized (this) { for (Batch batch : batches.values()) { batch.send(); } } }catch (Exception e){ logger.error("error while trying to send on stop",e); } } @Override public Notifier getNotifier() { return notifier; } // this is a hack because Google library can't parse exceptions properly when you have a bad API key private boolean isInvalidRequestException(IOException ie){ String message = ie.getMessage(); return message.contains("Could not post JSON requests to GCM"); } private class Batch { private Notifier notifier; private Map payload; private List<String> ids; private List<TaskTracker> trackers; private Map<String, Date> inactiveDevices = new HashMap<>(); Batch(Notifier notifier, Map<String,Object> payload) { this.notifier = notifier; this.payload = payload; this.ids = new ArrayList<>(); this.trackers = new ArrayList<>(); } synchronized Map<String, Date> getAndClearInactiveDevices() { Map<String, Date> map = inactiveDevices; inactiveDevices = new HashMap<String, Date>(); return map; } void add(String id, TaskTracker tracker) throws Exception { synchronized (this) { if(!ids.contains(id)) { //dedupe to a device ids.add(id); trackers.add(tracker); if (ids.size() == BATCH_SIZE) { send(); } }else{ tracker.completed(); } } } void send() throws Exception { synchronized (this) { if (ids.size() == 0) return; Sender sender = new Sender(notifier.getApiKey()); Message.Builder builder = new Message.Builder(); if(payload.containsKey(ttlKey)){ builder.timeToLive((int)payload.get(ttlKey)); payload.remove(ttlKey); } if(payload.containsKey(priorityKey)){ try{ builder.priority(Message.Priority.valueOf(payload.get(priorityKey).toString().toUpperCase())); }catch(Exception e){ // couldn't determine the priority from the notification, default to "normal" builder.priority(Message.Priority.NORMAL); } payload.remove(priorityKey); } builder.setData(payload); // GCM will accept Map<String,Object> but builder.build().toString() will throw a class cast // exception, but luckily Message.toString() is not used anywhere in the GCM SDK or Usergrid Message message = builder.build(); MulticastResult multicastResult; try{ multicastResult = sender.send(message, ids, SEND_RETRIES); }catch (IOException e) { if(isInvalidRequestException(e)){ String error = Constants.ERROR_INVALID_REGISTRATION; for(int i=0; i < ids.size(); i++){ trackers.get(i).failed(error, error); } this.ids.clear(); this.trackers.clear(); return; }else { throw new ConnectionException(e.getMessage(), e); } } if (logger.isTraceEnabled()) { logger.trace("sendNotification result: {}", multicastResult); } for (int i = 0; i < multicastResult.getResults().size(); i++) { Result result = multicastResult.getResults().get(i); if (result.getMessageId() != null) { String canonicalRegId = result.getCanonicalRegistrationId(); trackers.get(i).completed(canonicalRegId); } else { String error = result.getErrorCodeName(); trackers.get(i).failed(error, error); if (Constants.ERROR_NOT_REGISTERED.equals(error) || Constants.ERROR_INVALID_REGISTRATION.equals(error)) { inactiveDevices.put(ids.get(i), new Date()); } } } this.ids.clear(); this.trackers.clear(); } } } }