/** * JBoss, Home of Professional Open Source * Copyright Red Hat, Inc., and individual contributors. * * 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 org.jboss.aerogear.unifiedpush.message.sender; import com.notnoop.apns.APNS; import com.notnoop.apns.ApnsDelegateAdapter; import com.notnoop.apns.ApnsNotification; import com.notnoop.apns.ApnsService; import com.notnoop.apns.ApnsServiceBuilder; import com.notnoop.apns.DeliveryError; import com.notnoop.apns.EnhancedApnsNotification; import com.notnoop.apns.PayloadBuilder; import com.notnoop.apns.internal.Utilities; import com.notnoop.exceptions.ApnsDeliveryErrorException; import org.jboss.aerogear.unifiedpush.api.Variant; import org.jboss.aerogear.unifiedpush.api.VariantType; import org.jboss.aerogear.unifiedpush.api.iOSVariant; import org.jboss.aerogear.unifiedpush.message.InternalUnifiedPushMessage; import org.jboss.aerogear.unifiedpush.message.Message; import org.jboss.aerogear.unifiedpush.message.UnifiedPushMessage; import org.jboss.aerogear.unifiedpush.message.apns.APNs; import org.jboss.aerogear.unifiedpush.message.exception.PushNetworkUnreachableException; import org.jboss.aerogear.unifiedpush.message.exception.SenderResourceNotAvailableException; import org.jboss.aerogear.unifiedpush.message.serviceHolder.ApnsServiceHolder; import org.jboss.aerogear.unifiedpush.message.serviceHolder.ServiceConstructor; import org.jboss.aerogear.unifiedpush.message.serviceHolder.ServiceDestroyer; import org.jboss.aerogear.unifiedpush.service.ClientInstallationService; import org.jboss.aerogear.unifiedpush.service.proxy.ProxyConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; import java.io.ByteArrayInputStream; import java.util.Arrays; import java.util.Collection; import java.util.Date; import static org.jboss.aerogear.unifiedpush.system.ConfigurationUtils.tryGetIntegerProperty; import static org.jboss.aerogear.unifiedpush.system.ConfigurationUtils.tryGetProperty; @SenderType(VariantType.IOS) public class APNsPushNotificationSender implements PushNotificationSender { public static final String CUSTOM_AEROGEAR_APNS_PUSH_HOST = "custom.aerogear.apns.push.host"; public static final String CUSTOM_AEROGEAR_APNS_PUSH_PORT = "custom.aerogear.apns.push.port"; private static final String CUSTOM_AEROGEAR_APNS_FEEDBACK_HOST = "custom.aerogear.apns.feedback.host"; private static final String CUSTOM_AEROGEAR_APNS_FEEDBACK_PORT = "custom.aerogear.apns.feedback.port"; private static final String customAerogearApnsPushHost = tryGetProperty(CUSTOM_AEROGEAR_APNS_PUSH_HOST); private static final Integer customAerogearApnsPushPort = tryGetIntegerProperty(CUSTOM_AEROGEAR_APNS_PUSH_PORT); private static final String customAerogearApnsFeedbackHost = tryGetProperty(CUSTOM_AEROGEAR_APNS_FEEDBACK_HOST); private static final Integer customAerogearApnsFeedbackPort = tryGetIntegerProperty(CUSTOM_AEROGEAR_APNS_FEEDBACK_PORT); private final Logger logger = LoggerFactory.getLogger(APNsPushNotificationSender.class); @Inject private ClientInstallationService clientInstallationService; @Inject private ApnsServiceHolder apnsServiceHolder; public APNsPushNotificationSender() { } /** * Constructor used for test purposes */ APNsPushNotificationSender(ApnsServiceHolder apnsServiceHolder) { this.apnsServiceHolder = apnsServiceHolder; } /** * Sends APNs notifications ({@link UnifiedPushMessage}) to all devices, that are represented by * the {@link Collection} of tokens for the given {@link iOSVariant}. * * @param variant contains details for the underlying push network, e.g. API Keys/Ids * @param tokens contains the list of tokens that identifies the installation to which the message will be sent * @param pushMessage payload to be send to the given clients * @param pushMessageInformationId the id of the PushMessageInformation instance associated with this send. * @param callback that will be invoked after the sending. */ @Override public void sendPushMessage(final Variant variant, final Collection<String> tokens, final UnifiedPushMessage pushMessage, final String pushMessageInformationId, final NotificationSenderCallback callback) { // no need to send empty list if (tokens.isEmpty()) { return; } final iOSVariant iOSVariant = (iOSVariant) variant; Message message = pushMessage.getMessage(); APNs apns = message.getApns(); PayloadBuilder builder = APNS.newPayload() // adding recognized key values .alertBody(message.getAlert()) // alert dialog, in iOS or Safari .badge(message.getBadge()) // little badge icon update; .sound(message.getSound()) // sound to be played by app .alertTitle(apns.getTitle()) // The title of the notification in Safari and Apple Watch .alertAction(apns.getAction()) // The label of the action button, if the user sets the notifications to appear as alerts in Safari. .urlArgs(apns.getUrlArgs()) .category(apns.getActionCategory()) // iOS8: User Action category .localizedTitleKey(apns.getLocalizedTitleKey()); //iOS8 : Localized Title Key //this kind of check should belong in java-apns if(apns.getLocalizedTitleArguments() != null) { builder .localizedArguments(apns.getLocalizedTitleArguments()); //iOS8 : Localized Title Arguments; } // apply the 'content-available:1' value: if (apns.isContentAvailable()) { // content-available is for 'silent' notifications and Newsstand builder = builder.instantDeliveryOrSilentNotification(); } builder = builder.customFields(message.getUserData()); // adding other (submitted) fields //add aerogear-push-id builder = builder.customField(InternalUnifiedPushMessage.PUSH_MESSAGE_ID, pushMessageInformationId); // we are done with adding values here, before building let's check if the msg is too long if (builder.isTooLong()) { // invoke the error callback and return, as it is pointless to send something out callback.onError("Nothing sent to APNs since the payload is too large"); return; } // all good, let's build the JSON payload for APNs final String apnsMessage = builder.build(); final ApnsService service = apnsServiceHolder.dequeueOrCreateNewService(pushMessageInformationId, iOSVariant.getVariantID(), new ServiceConstructor<ApnsService>() { @Override public ApnsService construct() { ApnsService service = buildApnsService(iOSVariant, callback); if (service == null) { callback.onError("No certificate was found. Could not send messages to APNs"); throw new IllegalStateException("No certificate was found. Could not send messages to APNs"); } else { logger.debug("Starting APNs service"); try { service.start(); } catch (Exception e) { throw new PushNetworkUnreachableException(e); } return service; } } }); if (service == null) { throw new SenderResourceNotAvailableException("Unable to obtain a ApnsService instance"); } try { logger.debug("Sending transformed APNs payload: {}", apnsMessage); Date expireDate = createFutureDateBasedOnTTL(pushMessage.getConfig().getTimeToLive()); service.push(tokens, apnsMessage, expireDate); logger.info(String.format("Sent push notification to the Apple APNs Server for %d tokens",tokens.size())); apnsServiceHolder.queueFreedUpService(pushMessageInformationId, iOSVariant.getVariantID(), service, new ServiceDestroyer<ApnsService>() { @Override public void destroy(ApnsService instance) { service.stop(); } }); try { callback.onSuccess(); } catch (Exception e) { logger.error("Failed to call onSuccess after successful push", e); } } catch (Exception e) { try { logger.warn("APNs service died in the middle of sending, stopping it"); try { service.stop(); } catch (Exception ex) { logger.error("Failed to stop the APNs service after failure", ex); } callback.onError("Error sending payload to APNs server: " + e.getMessage()); } finally { apnsServiceHolder.freeUpSlot(pushMessageInformationId, iOSVariant.getVariantID()); } } } /** * Helper method that creates a future {@link Date}, based on the given ttl/time-to-live value. * If no TTL was provided, we use the max date from the APNs library */ private static Date createFutureDateBasedOnTTL(int ttl) { // no TTL was specified on the payload, we use the MAX Default from the APNs library: if (ttl == -1) { return new Date(EnhancedApnsNotification.MAXIMUM_EXPIRY * 1000L); } else { // apply the given TTL to the current time return new Date(System.currentTimeMillis() + ttl * 1000L); } } /** * Returns the ApnsService, based on the required profile (production VS sandbox/test). * Null is returned if there is no "configuration" for the request stage */ private ApnsService buildApnsService(final iOSVariant iOSVariant, final NotificationSenderCallback notificationSenderCallback) { // this check should not be needed, but you never know: if (iOSVariant.getCertificate() != null && iOSVariant.getPassphrase() != null) { final ApnsServiceBuilder builder = APNS.newService(); // using the APNS Delegate callback to log success/failure for each token: builder.withDelegate(new ApnsDelegateAdapter() { @Override public void messageSent(ApnsNotification message, boolean resent) { // Invoked for EVERY devicetoken: if (logger.isTraceEnabled()) { logger.trace("Sending APNs message to: {}", Arrays.toString(message.getDeviceToken())); } } @Override public void messageSendFailed(ApnsNotification message, Throwable e) { if (e.getClass().isAssignableFrom(ApnsDeliveryErrorException.class)) { ApnsDeliveryErrorException deliveryError = (ApnsDeliveryErrorException) e; if (deliveryError.getDeliveryError() == DeliveryError.INVALID_TOKEN) { final String invalidToken = Utilities.encodeHex(message.getDeviceToken()).toLowerCase(); logger.info("Removing invalid (not allowed) token: {}", invalidToken); clientInstallationService.removeInstallationForVariantByDeviceToken(iOSVariant.getVariantID(), invalidToken); } else { // for now, we just log the other cases logger.error("Error sending payload to APNs server", e); } } } }); // add the certificate: try { ByteArrayInputStream stream = new ByteArrayInputStream(iOSVariant.getCertificate()); builder.withCert(stream, iOSVariant.getPassphrase()); // release the stream stream.close(); } catch (Exception e) { logger.error("Error reading certificate", e); // indicating an incomplete service return null; } configureDestinations(iOSVariant, builder); if(ProxyConfiguration.hasSocks()){ logger.debug("configuring with socks proxy"); builder.withProxy(ProxyConfiguration.socks()); } // create the service return builder.build(); } // null if, why ever, there was no cert/passphrase return null; } /** * Configure the Gateway to the Apns servers. * Default gateway and port can be override with respectively : * - custom.aerogear.apns.push.host * - custom.aerogear.apns.push.port * * Feedback gateway and port can be override with respectively : * - custom.aerogear.apns.feedback.host * - custom.aerogear.apns.feedback.port * @param iOSVariant * @param builder */ private static void configureDestinations(iOSVariant iOSVariant, ApnsServiceBuilder builder) { // pick the destination, based on submitted profile: builder.withAppleDestination(iOSVariant.isProduction()); //Is the gateway host&port provided by a system property, for tests ? if(customAerogearApnsPushHost != null){ int port = Utilities.SANDBOX_GATEWAY_PORT; if(customAerogearApnsPushPort != null) { port = customAerogearApnsPushPort; } builder.withGatewayDestination(customAerogearApnsPushHost, port); } //Is the feedback gateway provided by a system property, for tests ? if(customAerogearApnsFeedbackHost != null){ int port = Utilities.SANDBOX_FEEDBACK_PORT; if(customAerogearApnsFeedbackPort != null) { port = customAerogearApnsFeedbackPort; } builder.withFeedbackDestination(customAerogearApnsFeedbackHost, port); } } }