From 15753b2ebdac2ab49cf5682c417851a0653e136e Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Tue, 25 Sep 2018 12:49:57 +0300 Subject: notifications server refactoring --- .../java/com/juick/components/APNSManager.java | 140 ++++++++ .../java/com/juick/components/FirebaseManager.java | 90 +++++ .../main/java/com/juick/components/MPNSClient.java | 100 ------ .../java/com/juick/components/MPNSManager.java | 163 +++++++++ .../components/NotificationClientListener.java | 8 - .../java/com/juick/components/Notifications.java | 374 --------------------- .../com/juick/components/NotificationsManager.java | 214 ++++++++++++ .../configuration/APNSConfiguration.java | 26 +- .../components/configuration/GCMConfiguration.java | 12 +- .../JuickServerWebsocketConfiguration.java | 7 +- .../configuration/MPNSConfiguration.java | 8 +- 11 files changed, 625 insertions(+), 517 deletions(-) create mode 100644 juick-notifications/src/main/java/com/juick/components/APNSManager.java create mode 100644 juick-notifications/src/main/java/com/juick/components/FirebaseManager.java delete mode 100644 juick-notifications/src/main/java/com/juick/components/MPNSClient.java create mode 100644 juick-notifications/src/main/java/com/juick/components/MPNSManager.java delete mode 100644 juick-notifications/src/main/java/com/juick/components/NotificationClientListener.java delete mode 100644 juick-notifications/src/main/java/com/juick/components/Notifications.java create mode 100644 juick-notifications/src/main/java/com/juick/components/NotificationsManager.java (limited to 'juick-notifications/src/main/java/com/juick/components') diff --git a/juick-notifications/src/main/java/com/juick/components/APNSManager.java b/juick-notifications/src/main/java/com/juick/components/APNSManager.java new file mode 100644 index 00000000..c0380847 --- /dev/null +++ b/juick-notifications/src/main/java/com/juick/components/APNSManager.java @@ -0,0 +1,140 @@ +package com.juick.components; + +import com.juick.ExternalToken; +import com.juick.User; +import com.juick.formatters.PlainTextFormatter; +import com.juick.service.component.*; +import com.turo.pushy.apns.ApnsClient; +import com.turo.pushy.apns.ApnsClientBuilder; +import com.turo.pushy.apns.PushNotificationResponse; +import com.turo.pushy.apns.auth.ApnsSigningKey; +import com.turo.pushy.apns.util.ApnsPayloadBuilder; +import com.turo.pushy.apns.util.SimpleApnsPushNotification; +import com.turo.pushy.apns.util.concurrent.PushNotificationResponseListener; +import io.netty.util.concurrent.Future; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +public class APNSManager implements NotificationListener { + private static Logger logger = LoggerFactory.getLogger(APNSManager.class); + + private ApnsClient apns; + @Value("${ios_p8_key:}") + private String p8key; + @Value("${ios_app_id:}") + private String topic; + @Value("${ios_team_id:}") + private String teamId; + @Value("${ios_key_id:}") + private String keyId; + @Inject + private NotificationsManager notificationsManager; + @PostConstruct + public void initialize() throws NoSuchAlgorithmException, InvalidKeyException, IOException { + apns = new ApnsClientBuilder() + .setApnsServer(ApnsClientBuilder.PRODUCTION_APNS_HOST) + .setSigningKey(ApnsSigningKey.loadFromPkcs8File(new File(p8key), + teamId, keyId)) + .build(); + } + @Override + public void processMessageEvent(MessageEvent messageEvent) { + com.juick.Message jmsg = messageEvent.getMessage(); + List users = messageEvent.getUsers(); + ApnsPayloadBuilder apnsPayloadBuilder = new ApnsPayloadBuilder(); + apnsPayloadBuilder.addCustomProperty("mid", jmsg.getMid()); + apnsPayloadBuilder.addCustomProperty("uname", jmsg.getUser().getName()); + String post = PlainTextFormatter.formatPost(jmsg); + String[] parts = post.split("\n", 2); + apnsPayloadBuilder.setAlertTitle(parts[0]).setAlertBody(parts[1]); + users.forEach( user -> { + apnsPayloadBuilder.setBadgeNumber(user.getUnreadCount()); + String payload = apnsPayloadBuilder.buildWithDefaultMaximumLength(); + user.getTokens().stream().filter(t -> t.getType().equals("apns")) + .map(ExternalToken::getToken).forEach(token -> { + Future> notification = apns.sendNotification( + new SimpleApnsPushNotification(token, topic, payload)); + notification.addListener((PushNotificationResponseListener) future -> { + if (future.isSuccess()) { + processAPNSResponse(token, future.getNow()); + } else { + logger.warn("APNS error ", future.cause()); + } + }); + }); + }); + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + List users = messageReadEvent.getUsers(); + ApnsPayloadBuilder apnsPayloadBuilder = new ApnsPayloadBuilder(); + users.forEach(user -> { + apnsPayloadBuilder.setBadgeNumber(user.getUnreadCount()); + String payload = apnsPayloadBuilder.buildWithDefaultMaximumLength(); + user.getTokens().stream().filter(t -> t.getType().equals("apns")) + .map(ExternalToken::getToken).forEach(token -> { + Future> notification = apns.sendNotification( + new SimpleApnsPushNotification(token, topic, payload)); + notification.addListener((PushNotificationResponseListener) future -> { + if (future.isSuccess()) { + processAPNSResponse(token, future.getNow()); + } else { + logger.warn("APNS error ", future.cause()); + } + }); + }); + }); + } + @PreDestroy + public void close() { + apns.close(); + } + + private void processAPNSResponse(String token, PushNotificationResponse pushNotificationResponse) { + if (pushNotificationResponse.isAccepted()) { + logger.info("APNS accepted: {}", token); + } else { + String reason = pushNotificationResponse.getRejectionReason(); + logger.info("APNS rejected: {}", reason); + if (reason.equals("BadDeviceToken")) { + notificationsManager.getInvalidAPNSTokens().add(token); + } + } + Optional invalidationDate = Optional.ofNullable( + pushNotificationResponse.getTokenInvalidationTimestamp()); + invalidationDate.ifPresent(date -> { + if (date.before(new Date())) { + logger.info("Token invalidated: {}", token); + notificationsManager.getInvalidAPNSTokens().add(token); + } + }); + } +} diff --git a/juick-notifications/src/main/java/com/juick/components/FirebaseManager.java b/juick-notifications/src/main/java/com/juick/components/FirebaseManager.java new file mode 100644 index 00000000..54e7c97a --- /dev/null +++ b/juick-notifications/src/main/java/com/juick/components/FirebaseManager.java @@ -0,0 +1,90 @@ +package com.juick.components; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.android.gcm.server.*; +import com.juick.ExternalToken; +import com.juick.User; +import com.juick.service.component.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class FirebaseManager implements NotificationListener { + private static Logger logger = LoggerFactory.getLogger(FirebaseManager.class); + @Inject + ObjectMapper jsonMapper; + @Value("${gcm_key:}") + private String gcmKey; + @Inject + private NotificationsManager notificationsManager; + + private Sender GCMSender; + + @PostConstruct + public void initialize() { + GCMSender = new Sender(gcmKey, Endpoint.GCM); + } + + @Override + public void processMessageEvent(MessageEvent messageEvent) { + com.juick.Message jmsg = messageEvent.getMessage(); + List users = messageEvent.getUsers(); + // GCM + List regids = users.stream().flatMap(u -> u.getTokens().stream()).filter(d -> d.getType().equals("gcm")) + .map(ExternalToken::getToken).collect(Collectors.toList()); + if (!regids.isEmpty()) { + try { + String json = jsonMapper.writeValueAsString(jmsg); + logger.info(json); + Message message = new Message.Builder().addData("message", json).build(); + MulticastResult result = GCMSender.send(message, regids, 3); + List results = result.getResults(); + for (int i = 0; i < results.size(); i++) { + Result currentResult = results.get(i); + logger.info("RES {}: {}", i, currentResult); + List errorCodes = Arrays.asList(Constants.ERROR_MISMATCH_SENDER_ID, Constants.ERROR_NOT_REGISTERED); + if (errorCodes.contains(currentResult.getErrorCodeName())) { + // assuming results are in order of regids + // http://stackoverflow.com/a/11594531/1097384 + String currentId = regids.get(i); + logger.info("{} is scheduled to remove", currentId); + notificationsManager.addInvalidGCMToken(currentId); + } + } + } catch (IOException ex) { + logger.error(ex.getMessage(), ex); + } catch (IllegalArgumentException err) { + logger.warn("Android: Invalid API Key", err); + } + } else { + logger.info("GMS: no recipients"); + } + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } +} diff --git a/juick-notifications/src/main/java/com/juick/components/MPNSClient.java b/juick-notifications/src/main/java/com/juick/components/MPNSClient.java deleted file mode 100644 index efa47bb1..00000000 --- a/juick-notifications/src/main/java/com/juick/components/MPNSClient.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.juick.components; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.juick.components.mpns.MPNSError; -import com.juick.components.mpns.MPNSToken; -import org.apache.commons.collections4.MapUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import javax.annotation.PostConstruct; -import javax.inject.Inject; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.stream.Collectors; - -/** - * Created by vital on 29.03.2017. - */ -public class MPNSClient { - - private static Logger logger = LoggerFactory.getLogger(MPNSClient.class); - - private String accessToken; - - private NotificationClientListener listener; - - @Inject - private ObjectMapper jsonMapper; - @Value("${wns_application_sip:}") - private String applicationSip; - @Value("${wns_client_secret:}") - private String applicationSecret; - private RestTemplate wnsService; - - @PostConstruct - public void authenticate() throws IOException { - String url = "https://login.live.com/accesstoken.srf"; - MultiValueMap form = new LinkedMultiValueMap<>(); - form.add("grant_type", "client_credentials"); - form.add("client_id", applicationSip); - form.add("client_secret", applicationSecret); - form.add("scope", "notify.windows.com"); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - wnsService = new RestTemplate(); - wnsService.getMessageConverters().add(new StringHttpMessageConverter(StandardCharsets.UTF_8)); - HttpEntity> entity = new HttpEntity<>(form, httpHeaders); - ResponseEntity response = wnsService.exchange(url, HttpMethod.POST, entity, String.class); - String responseBody = response.getBody(); - HttpStatus statusCode = response.getStatusCode(); - if (statusCode != HttpStatus.OK) { - MPNSError error = jsonMapper.readValue(responseBody, MPNSError.class); - throw new IOException(error.getError() + ": " + error.getErrorDescription()); - } - MPNSToken token = jsonMapper.readValue(responseBody, MPNSToken.class); - if (token.getTokenType().length() >= 1) { - token.setTokenType(Character.toUpperCase(token.getTokenType().charAt(0)) + token.getTokenType().substring(1)); - } - accessToken = token.getTokenType() + " " + token.getAccessToken(); - logger.info("MPNS authenticated"); - } - - void sendNotification(final String url, final String xml) throws IOException { - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setContentType(new MediaType("text", "xml", StandardCharsets.UTF_8)); - httpHeaders.set("Authorization", accessToken); - httpHeaders.set("X-WNS-Type", "wns/toast"); - HttpEntity requestEntity = new HttpEntity<>(xml, httpHeaders); - try { - UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); - wnsService.exchange(builder.build(true).toUri(), HttpMethod.POST, requestEntity, Void.class); - } catch (HttpClientErrorException ex) { - HttpStatus statusCode = ex.getStatusCode(); - if (statusCode == HttpStatus.GONE) { - // expired - logger.info("{} is scheduled to remove", url); - listener.invalidToken("mpns", url); - } else { - String headersContent = ex.getResponseHeaders().entrySet().stream() - .filter(x -> x.getKey().startsWith("X-WNS-") || x.getKey().startsWith("WWW-")) - .map(x -> x.getKey() + ": " + String.join(",", x.getValue())) - .collect(Collectors.joining("\n")); - throw new IOException(headersContent); - } - } - } - - - public void setListener(NotificationClientListener listener) { - this.listener = listener; - } -} diff --git a/juick-notifications/src/main/java/com/juick/components/MPNSManager.java b/juick-notifications/src/main/java/com/juick/components/MPNSManager.java new file mode 100644 index 00000000..56c2df8d --- /dev/null +++ b/juick-notifications/src/main/java/com/juick/components/MPNSManager.java @@ -0,0 +1,163 @@ +package com.juick.components; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.ExternalToken; +import com.juick.User; +import com.juick.components.mpns.MPNSError; +import com.juick.components.mpns.MPNSToken; +import com.juick.service.component.*; +import com.juick.util.MessageUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Created by vital on 29.03.2017. + */ +public class MPNSManager implements NotificationListener { + + private static Logger logger = LoggerFactory.getLogger(MPNSManager.class); + + private String accessToken; + + @Inject + private ObjectMapper jsonMapper; + @Value("${wns_application_sip:}") + private String applicationSip; + @Value("${wns_client_secret:}") + private String applicationSecret; + private RestTemplate wnsService; + + @Inject + private NotificationsManager notificationsManager; + + @PostConstruct + public void authenticate() throws IOException { + String url = "https://login.live.com/accesstoken.srf"; + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "client_credentials"); + form.add("client_id", applicationSip); + form.add("client_secret", applicationSecret); + form.add("scope", "notify.windows.com"); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + wnsService = new RestTemplate(); + wnsService.getMessageConverters().add(new StringHttpMessageConverter(StandardCharsets.UTF_8)); + HttpEntity> entity = new HttpEntity<>(form, httpHeaders); + ResponseEntity response = wnsService.exchange(url, HttpMethod.POST, entity, String.class); + String responseBody = response.getBody(); + HttpStatus statusCode = response.getStatusCode(); + if (statusCode != HttpStatus.OK) { + MPNSError error = jsonMapper.readValue(responseBody, MPNSError.class); + throw new IOException(error.getError() + ": " + error.getErrorDescription()); + } + MPNSToken token = jsonMapper.readValue(responseBody, MPNSToken.class); + if (token.getTokenType().length() >= 1) { + token.setTokenType(Character.toUpperCase(token.getTokenType().charAt(0)) + token.getTokenType().substring(1)); + } + accessToken = token.getTokenType() + " " + token.getAccessToken(); + logger.info("MPNS authenticated"); + } + + void sendNotification(final String url, final String xml) throws IOException { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(new MediaType("text", "xml", StandardCharsets.UTF_8)); + httpHeaders.set("Authorization", accessToken); + httpHeaders.set("X-WNS-Type", "wns/toast"); + HttpEntity requestEntity = new HttpEntity<>(xml, httpHeaders); + try { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); + wnsService.exchange(builder.build(true).toUri(), HttpMethod.POST, requestEntity, Void.class); + } catch (HttpClientErrorException ex) { + HttpStatus statusCode = ex.getStatusCode(); + if (statusCode == HttpStatus.GONE) { + // expired + logger.info("{} is scheduled to remove", url); + notificationsManager.addInvalidMPNSToken(url); + } else { + String headersContent = ex.getResponseHeaders().entrySet().stream() + .filter(x -> x.getKey().startsWith("X-WNS-") || x.getKey().startsWith("WWW-")) + .map(x -> x.getKey() + ": " + String.join(",", x.getValue())) + .collect(Collectors.joining("\n")); + throw new IOException(headersContent); + } + } + } + + @Override + public void processMessageEvent(MessageEvent messageEvent) { + com.juick.Message jmsg = messageEvent.getMessage(); + List users = messageEvent.getUsers(); + + List urls = users.stream().flatMap(u -> u.getTokens().stream()).filter(d -> d.getType().equals("mpns")) + .map(ExternalToken::getToken).collect(Collectors.toList()); + + if (urls.isEmpty()) { + logger.info("WNS: no recipients"); + } else { + try { + String text1 = "@" + jmsg.getUser().getName(); + if (!jmsg.getTags().isEmpty()) { + text1 += ":" + StringEscapeUtils.escapeXml11(MessageUtils.getTagsString(jmsg)); + } + String text2 = StringEscapeUtils.escapeXml11(StringUtils.defaultString(jmsg.getText())); + String xml = "" + + "" + + "" + + "" + + "" + + "" + text1 + "" + + "" + text2 + "" + + "" + + "" + + "" + + "" + + "" + + ""; + logger.trace(xml); + for (String url : urls) { + logger.info("WNS: {}", url); + sendNotification(url, xml); + } + } catch (IOException | IllegalStateException ex) { + logger.error("WNS: ", ex); + } + } + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } +} diff --git a/juick-notifications/src/main/java/com/juick/components/NotificationClientListener.java b/juick-notifications/src/main/java/com/juick/components/NotificationClientListener.java deleted file mode 100644 index 46bd683f..00000000 --- a/juick-notifications/src/main/java/com/juick/components/NotificationClientListener.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.juick.components; - -/** - * Created by vital on 29.03.2017. - */ -public interface NotificationClientListener { - void invalidToken(String type, String token); -} diff --git a/juick-notifications/src/main/java/com/juick/components/Notifications.java b/juick-notifications/src/main/java/com/juick/components/Notifications.java deleted file mode 100644 index 1842593a..00000000 --- a/juick-notifications/src/main/java/com/juick/components/Notifications.java +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Juick - * Copyright (C) 2013, Ugnich Anton - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package com.juick.components; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.android.gcm.server.*; -import com.juick.ExternalToken; -import com.juick.User; -import com.juick.formatters.PlainTextFormatter; -import com.juick.service.component.DisconnectedEvent; -import com.juick.util.MessageUtils; -import com.turo.pushy.apns.ApnsClient; -import com.turo.pushy.apns.PushNotificationResponse; -import com.turo.pushy.apns.util.ApnsPayloadBuilder; -import com.turo.pushy.apns.util.SimpleApnsPushNotification; -import com.turo.pushy.apns.util.concurrent.PushNotificationResponseListener; -import io.netty.util.concurrent.Future; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.text.StringEscapeUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.PingMessage; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -import javax.annotation.Nonnull; -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import javax.inject.Inject; -import java.io.IOException; -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; - -/** - * @author Ugnich Anton - */ -@Component -public class Notifications extends TextWebSocketHandler implements NotificationClientListener { - private static Logger logger = LoggerFactory.getLogger(Notifications.class); - - @Inject - private RestTemplate rest; - @Inject - private Sender GCMSender; - - @Inject - private ObjectMapper jsonMapper; - - private final Set invalidGCMTokens = Collections.synchronizedSet(new HashSet<>()); - private final Set invalidMPNSTokens = Collections.synchronizedSet(new HashSet<>()); - private final Set invalidAPNSTokens = Collections.synchronizedSet(new HashSet<>()); - - @Inject - private MPNSClient mpnsClient; - @Inject - private ApnsClient apns; - @Value("${ios_app_id:}") - private String topic; - - @PostConstruct - public void init() { - mpnsClient.setListener(this); - closeFlag.set(false); - } - public void messageReceived(@Nonnull com.juick.Message jmsg) { - if (jmsg.isService()) { - serviceMessageReceived(jmsg); - return; - } - User pmTo = jmsg.getTo(); - final List users = new ArrayList<>(); - if (MessageUtils.isPM(jmsg)) { - users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%d", - pmTo.getUid()), - HttpMethod.GET, null, new ParameterizedTypeReference>() { - }).getBody()); - } else if (MessageUtils.isReply(jmsg)) { - users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%d&mid=%d&rid=%d", - jmsg.getUser().getUid(), jmsg.getMid(), jmsg.getRid()), - HttpMethod.GET, null, new ParameterizedTypeReference>() { - }).getBody()); - } else { - users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%s&mid=%s", - jmsg.getUser().getUid(), jmsg.getMid()), - HttpMethod.GET, null, new ParameterizedTypeReference>() { - }).getBody()); - } - - // GCM - List regids = users.stream().flatMap(u -> u.getTokens().stream()).filter(d -> d.getType().equals("gcm")) - .map(ExternalToken::getToken).collect(Collectors.toList()); - if (!regids.isEmpty()) { - try { - String json = jsonMapper.writeValueAsString(jmsg); - logger.info(json); - Message message = new Message.Builder().addData("message", json).build(); - MulticastResult result = GCMSender.send(message, regids, 3); - List results = result.getResults(); - for (int i = 0; i < results.size(); i++) { - Result currentResult = results.get(i); - logger.info("RES {}: {}", i, currentResult); - List errorCodes = Arrays.asList(Constants.ERROR_MISMATCH_SENDER_ID, Constants.ERROR_NOT_REGISTERED); - if (errorCodes.contains(currentResult.getErrorCodeName())) { - // assuming results are in order of regids - // http://stackoverflow.com/a/11594531/1097384 - String currentId = regids.get(i); - logger.info("{} is scheduled to remove", currentId); - addInvalidGCMToken(currentId); - } - } - } catch (IOException ex) { - logger.error(ex.getMessage(), ex); - } catch (IllegalArgumentException err) { - logger.warn("Android: Invalid API Key", err); - } - } else { - logger.info("GMS: no recipients"); - } - - /*** WinPhone ***/ - List urls = users.stream().flatMap(u -> u.getTokens().stream()).filter(d -> d.getType().equals("mpns")) - .map(ExternalToken::getToken).collect(Collectors.toList()); - - if (urls.isEmpty()) { - logger.info("WNS: no recipients"); - } else { - try { - String text1 = "@" + jmsg.getUser().getName(); - if (!jmsg.getTags().isEmpty()) { - text1 += ":" + StringEscapeUtils.escapeXml11(MessageUtils.getTagsString(jmsg)); - } - String text2 = StringEscapeUtils.escapeXml11(StringUtils.defaultString(jmsg.getText())); - String xml = "" - + "" - + "" - + "" - + "" - + "" + text1 + "" - + "" + text2 + "" - + "" - + "" - + "" - + "" - + "" - + ""; - logger.trace(xml); - for (String url : urls) { - logger.info("WNS: {}", url); - mpnsClient.sendNotification(url, xml); - } - } catch (IOException | IllegalStateException ex) { - logger.error("WNS: ", ex); - } - } - - /*** iOS ***/ - ApnsPayloadBuilder apnsPayloadBuilder = new ApnsPayloadBuilder(); - apnsPayloadBuilder.addCustomProperty("mid", jmsg.getMid()); - apnsPayloadBuilder.addCustomProperty("uname", jmsg.getUser().getName()); - String post = PlainTextFormatter.formatPost(jmsg); - String[] parts = post.split("\n", 2); - apnsPayloadBuilder.setAlertTitle(parts[0]).setAlertBody(parts[1]); - users.forEach( user -> { - apnsPayloadBuilder.setBadgeNumber(user.getUnreadCount()); - String payload = apnsPayloadBuilder.buildWithDefaultMaximumLength(); - user.getTokens().stream().filter(t -> t.getType().equals("apns")) - .map(ExternalToken::getToken).forEach(token -> { - Future> notification = apns.sendNotification( - new SimpleApnsPushNotification(token, topic, payload)); - notification.addListener((PushNotificationResponseListener) future -> { - if (future.isSuccess()) { - processAPNSResponse(token, future.getNow()); - } else { - logger.warn("APNS error ", future.cause()); - } - }); - }); - }); - } - private void serviceMessageReceived(@Nonnull com.juick.Message jmsg) { - logger.info("Message read event from {} for {}", jmsg.getUser().getName(), jmsg.getMid()); - // iOS - List users = rest.exchange(String.format("http://api.juick.com/notifications?uid=%d", - jmsg.getUser().getUid()), - HttpMethod.GET, null, new ParameterizedTypeReference>() { - }).getBody(); - ApnsPayloadBuilder apnsPayloadBuilder = new ApnsPayloadBuilder(); - users.forEach(user -> { - apnsPayloadBuilder.setBadgeNumber(user.getUnreadCount()); - String payload = apnsPayloadBuilder.buildWithDefaultMaximumLength(); - user.getTokens().stream().filter(t -> t.getType().equals("apns")) - .map(ExternalToken::getToken).forEach(token -> { - Future> notification = apns.sendNotification( - new SimpleApnsPushNotification(token, topic, payload)); - notification.addListener((PushNotificationResponseListener) future -> { - if (future.isSuccess()) { - processAPNSResponse(token, future.getNow()); - } else { - logger.warn("APNS error ", future.cause()); - } - }); - }); - }); - } - - private void processAPNSResponse(String token, PushNotificationResponse pushNotificationResponse) { - if (!closeFlag.get()) { - if (pushNotificationResponse.isAccepted()) { - logger.info("APNS accepted: {}", token); - } else { - String reason = pushNotificationResponse.getRejectionReason(); - logger.info("APNS rejected: {}", reason); - if (reason.equals("BadDeviceToken")) { - invalidAPNSTokens.add(token); - } - } - Optional invalidationDate = Optional.ofNullable( - pushNotificationResponse.getTokenInvalidationTimestamp()); - invalidationDate.ifPresent(date -> { - if (date.before(new Date())) { - logger.info("Token invalidated: {}", token); - invalidAPNSTokens.add(token); - } - }); - } - } - - public void addInvalidGCMToken(String token) { - synchronized (invalidGCMTokens) { - invalidGCMTokens.add(token); - } - } - public Set getInvalidGCMTokens() { - return invalidGCMTokens; - } - public void cleanupGCMTokens() { - logger.info("removed {} GCM tokens", invalidGCMTokens.size()); - synchronized (invalidGCMTokens) { - invalidGCMTokens.clear(); - } - } - public void addInvalidMPNSToken(String token) { - synchronized (invalidMPNSTokens) { - invalidMPNSTokens.add(token); - } - } - public Set getInvalidMPNSTokens() { - return invalidMPNSTokens; - } - public void cleanupMPNSTokens() { - logger.info("removed {} MPNS tokens", invalidMPNSTokens.size()); - synchronized (invalidMPNSTokens) { - invalidMPNSTokens.clear(); - } - } - - @Override - public void invalidToken(String type, String token) { - switch (type) { - case "mpns": - addInvalidMPNSToken(token); - break; - default: - break; - } - } - - public Set getInvalidAPNSTokens() { - return invalidAPNSTokens; - } - @Inject - private ApplicationEventPublisher applicationEventPublisher; - - private ConcurrentWebSocketSessionDecorator session; - private final AtomicBoolean closeFlag = new AtomicBoolean(false); - - @Override - public void afterConnectionEstablished(WebSocketSession session) { - if (!closeFlag.get()) { - logger.info("WebSocket connected"); - this.session = new ConcurrentWebSocketSessionDecorator(session, 60000, 65535); - } - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - if (!closeFlag.get()) { - logger.info("WebSocket disconnected with code {}: {}", status.getCode(), status.getReason()); - applicationEventPublisher.publishEvent(new DisconnectedEvent(this)); - } - } - - @Override - protected void handleTextMessage(WebSocketSession session, TextMessage text) throws Exception { - if (!closeFlag.get() && this.session.getDelegate().equals(session)) { - com.juick.Message jmsg = jsonMapper.readValue(text.asBytes(), com.juick.Message.class); - - if (logger.isInfoEnabled()) // prevent writeValueAsString execution if logger disabled - logger.info("got jmsg: {}", jsonMapper.writeValueAsString(jmsg)); - messageReceived(jmsg); - } - } - - @Scheduled(fixedRate = 30000, initialDelay = 30000) - public void ping() throws IOException { - if (!closeFlag.get()) { - if (session != null && session.isOpen()) { - logger.debug("Sending WebSocket ping"); - session.sendMessage(new PingMessage()); - } else { - applicationEventPublisher.publishEvent(new DisconnectedEvent(this)); - } - } - } - @PreDestroy - public void close() { - apns.close(); - closeFlag.set(true); - } - @Scheduled(fixedRate = 600000) - public void cleanupTokens() { - if (!closeFlag.get()) { - logger.debug("initializing GCM tokens cleanup: {} tokens", getInvalidGCMTokens().size()); - deleteTokens("gcm", new ArrayList<>(getInvalidGCMTokens())); - cleanupGCMTokens(); - logger.debug("initializing MPNS tokens cleanup: {} tokens", getInvalidMPNSTokens().size()); - deleteTokens("mpns", new ArrayList<>(getInvalidMPNSTokens())); - cleanupMPNSTokens(); - logger.debug("initializing APNS tokens cleanup: {} tokens", getInvalidAPNSTokens().size()); - deleteTokens("apns", new ArrayList<>(getInvalidAPNSTokens())); - cleanupMPNSTokens(); - } - } - private void deleteTokens(String type, List devices) { - if (devices.size() > 0) { - List list = devices.stream() - .map(d -> new ExternalToken(null, type, d, null)).collect(Collectors.toList()); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON_UTF8); - rest.exchange("http://api.juick.com/notifications", - HttpMethod.DELETE, new HttpEntity<>(list, headers), new ParameterizedTypeReference() { - }); - } - } -} diff --git a/juick-notifications/src/main/java/com/juick/components/NotificationsManager.java b/juick-notifications/src/main/java/com/juick/components/NotificationsManager.java new file mode 100644 index 00000000..be97ea40 --- /dev/null +++ b/juick-notifications/src/main/java/com/juick/components/NotificationsManager.java @@ -0,0 +1,214 @@ +/* + * Juick + * Copyright (C) 2013, Ugnich Anton + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.juick.components; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.ExternalToken; +import com.juick.User; +import com.juick.service.component.DisconnectedEvent; +import com.juick.service.component.MessageEvent; +import com.juick.service.component.MessageReadEvent; +import com.juick.util.MessageUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PingMessage; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** + * @author Ugnich Anton + */ +@Component +public class NotificationsManager extends TextWebSocketHandler { + private static Logger logger = LoggerFactory.getLogger(NotificationsManager.class); + + @Inject + private RestTemplate rest; + + @Inject + private ObjectMapper jsonMapper; + + private final Set invalidGCMTokens = Collections.synchronizedSet(new HashSet<>()); + private final Set invalidMPNSTokens = Collections.synchronizedSet(new HashSet<>()); + private final Set invalidAPNSTokens = Collections.synchronizedSet(new HashSet<>()); + + @PostConstruct + public void init() { + closeFlag.set(false); + } + public void messageReceived(@Nonnull com.juick.Message jmsg) { + if (jmsg.isService()) { + serviceMessageReceived(jmsg); + return; + } + User pmTo = jmsg.getTo(); + final List users = new ArrayList<>(); + if (MessageUtils.isPM(jmsg)) { + users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%d", + pmTo.getUid()), + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }).getBody()); + } else if (MessageUtils.isReply(jmsg)) { + users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%d&mid=%d&rid=%d", + jmsg.getUser().getUid(), jmsg.getMid(), jmsg.getRid()), + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }).getBody()); + } else { + users.addAll(rest.exchange(String.format("http://api.juick.com/notifications?uid=%s&mid=%s", + jmsg.getUser().getUid(), jmsg.getMid()), + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }).getBody()); + } + applicationEventPublisher.publishEvent(new MessageEvent(this, jmsg, users)); + } + private void serviceMessageReceived(@Nonnull com.juick.Message jmsg) { + logger.info("Message read event from {} for {}", jmsg.getUser().getName(), jmsg.getMid()); + List users = rest.exchange(String.format("http://api.juick.com/notifications?uid=%d", + jmsg.getUser().getUid()), + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }).getBody(); + applicationEventPublisher.publishEvent(new MessageReadEvent(this, users, jmsg)); + } + + public void addInvalidGCMToken(String token) { + synchronized (invalidGCMTokens) { + invalidGCMTokens.add(token); + } + } + public Set getInvalidGCMTokens() { + return invalidGCMTokens; + } + public void cleanupGCMTokens() { + logger.info("removed {} GCM tokens", invalidGCMTokens.size()); + synchronized (invalidGCMTokens) { + invalidGCMTokens.clear(); + } + } + public void addInvalidMPNSToken(String token) { + synchronized (invalidMPNSTokens) { + invalidMPNSTokens.add(token); + } + } + public Set getInvalidMPNSTokens() { + return invalidMPNSTokens; + } + public void cleanupMPNSTokens() { + logger.info("removed {} MPNS tokens", invalidMPNSTokens.size()); + synchronized (invalidMPNSTokens) { + invalidMPNSTokens.clear(); + } + } + + public Set getInvalidAPNSTokens() { + return invalidAPNSTokens; + } + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + private ConcurrentWebSocketSessionDecorator session; + private final AtomicBoolean closeFlag = new AtomicBoolean(false); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + if (!closeFlag.get()) { + logger.info("WebSocket connected"); + this.session = new ConcurrentWebSocketSessionDecorator(session, 60000, 65535); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + if (!closeFlag.get()) { + logger.info("WebSocket disconnected with code {}: {}", status.getCode(), status.getReason()); + applicationEventPublisher.publishEvent(new DisconnectedEvent(this)); + } + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage text) throws Exception { + if (!closeFlag.get() && this.session.getDelegate().equals(session)) { + com.juick.Message jmsg = jsonMapper.readValue(text.asBytes(), com.juick.Message.class); + + if (logger.isInfoEnabled()) // prevent writeValueAsString execution if logger disabled + logger.info("got jmsg: {}", jsonMapper.writeValueAsString(jmsg)); + messageReceived(jmsg); + } + } + + @Scheduled(fixedRate = 30000, initialDelay = 30000) + public void ping() throws IOException { + if (!closeFlag.get()) { + if (session != null && session.isOpen()) { + logger.debug("Sending WebSocket ping"); + session.sendMessage(new PingMessage()); + } else { + applicationEventPublisher.publishEvent(new DisconnectedEvent(this)); + } + } + } + @PreDestroy + public void close() { + closeFlag.set(true); + } + @Scheduled(fixedRate = 600000) + public void cleanupTokens() { + if (!closeFlag.get()) { + logger.debug("initializing GCM tokens cleanup: {} tokens", getInvalidGCMTokens().size()); + deleteTokens("gcm", new ArrayList<>(getInvalidGCMTokens())); + cleanupGCMTokens(); + logger.debug("initializing MPNS tokens cleanup: {} tokens", getInvalidMPNSTokens().size()); + deleteTokens("mpns", new ArrayList<>(getInvalidMPNSTokens())); + cleanupMPNSTokens(); + logger.debug("initializing APNS tokens cleanup: {} tokens", getInvalidAPNSTokens().size()); + deleteTokens("apns", new ArrayList<>(getInvalidAPNSTokens())); + cleanupMPNSTokens(); + } + } + private void deleteTokens(String type, List devices) { + if (devices.size() > 0) { + List list = devices.stream() + .map(d -> new ExternalToken(null, type, d, null)).collect(Collectors.toList()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON_UTF8); + rest.exchange("http://api.juick.com/notifications", + HttpMethod.DELETE, new HttpEntity<>(list, headers), new ParameterizedTypeReference() { + }); + } + } +} diff --git a/juick-notifications/src/main/java/com/juick/components/configuration/APNSConfiguration.java b/juick-notifications/src/main/java/com/juick/components/configuration/APNSConfiguration.java index 43ea3943..fbc17bc9 100644 --- a/juick-notifications/src/main/java/com/juick/components/configuration/APNSConfiguration.java +++ b/juick-notifications/src/main/java/com/juick/components/configuration/APNSConfiguration.java @@ -1,34 +1,18 @@ package com.juick.components.configuration; -import com.turo.pushy.apns.ApnsClient; -import com.turo.pushy.apns.ApnsClientBuilder; -import com.turo.pushy.apns.auth.ApnsSigningKey; -import org.springframework.beans.factory.annotation.Value; +import com.juick.components.APNSManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.io.File; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - /** * Created by vital on 28.03.2017. */ @Configuration +@ConditionalOnProperty(name = "ios_p8_key") public class APNSConfiguration { - @Value("${ios_p8_key:}") - private String p8key; - @Value("${ios_team_id:}") - private String teamId; - @Value("${ios_key_id:}") - private String keyId; @Bean - public ApnsClient apns() throws NoSuchAlgorithmException, InvalidKeyException, IOException { - return new ApnsClientBuilder() - .setApnsServer(ApnsClientBuilder.PRODUCTION_APNS_HOST) - .setSigningKey(ApnsSigningKey.loadFromPkcs8File(new File(p8key), - teamId, keyId)) - .build(); + public APNSManager apnsManager() { + return new APNSManager(); } } diff --git a/juick-notifications/src/main/java/com/juick/components/configuration/GCMConfiguration.java b/juick-notifications/src/main/java/com/juick/components/configuration/GCMConfiguration.java index 27e1af0f..68d9f017 100644 --- a/juick-notifications/src/main/java/com/juick/components/configuration/GCMConfiguration.java +++ b/juick-notifications/src/main/java/com/juick/components/configuration/GCMConfiguration.java @@ -1,8 +1,7 @@ package com.juick.components.configuration; -import com.google.android.gcm.server.Endpoint; -import com.google.android.gcm.server.Sender; -import org.springframework.beans.factory.annotation.Value; +import com.juick.components.FirebaseManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,12 +9,11 @@ import org.springframework.context.annotation.Configuration; * Created by vital on 29.03.2017. */ @Configuration +@ConditionalOnProperty(name = "gcm_key") public class GCMConfiguration { - @Value("${gcm_key:}") - private String gcmKey; @Bean - public Sender GCMSender() { - return new Sender(gcmKey, Endpoint.GCM); + public FirebaseManager firebaseManager() { + return new FirebaseManager(); } } diff --git a/juick-notifications/src/main/java/com/juick/components/configuration/JuickServerWebsocketConfiguration.java b/juick-notifications/src/main/java/com/juick/components/configuration/JuickServerWebsocketConfiguration.java index c35dfc7d..deb0cb5b 100644 --- a/juick-notifications/src/main/java/com/juick/components/configuration/JuickServerWebsocketConfiguration.java +++ b/juick-notifications/src/main/java/com/juick/components/configuration/JuickServerWebsocketConfiguration.java @@ -1,14 +1,13 @@ package com.juick.components.configuration; import com.fasterxml.jackson.databind.ObjectMapper; -import com.juick.components.Notifications; +import com.juick.components.NotificationsManager; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpRequestInterceptor; @@ -40,7 +39,7 @@ public class JuickServerWebsocketConfiguration { @Inject ObjectMapper jsonMapper; @Inject - private Notifications notifications; + private NotificationsManager notificationsManager; @Bean public RestTemplate rest() { RestTemplate rest = new RestTemplate(); @@ -62,7 +61,7 @@ public class JuickServerWebsocketConfiguration { } String websocketURI = UriComponentsBuilder.fromUriString(baseUri) .queryParam("hash", hash).build().toUriString(); - WebSocketConnectionManager manager = new WebSocketConnectionManager(client(), notifications, websocketURI); + WebSocketConnectionManager manager = new WebSocketConnectionManager(client(), notificationsManager, websocketURI); return manager; } @Bean diff --git a/juick-notifications/src/main/java/com/juick/components/configuration/MPNSConfiguration.java b/juick-notifications/src/main/java/com/juick/components/configuration/MPNSConfiguration.java index f849b159..4235486c 100644 --- a/juick-notifications/src/main/java/com/juick/components/configuration/MPNSConfiguration.java +++ b/juick-notifications/src/main/java/com/juick/components/configuration/MPNSConfiguration.java @@ -1,6 +1,7 @@ package com.juick.components.configuration; -import com.juick.components.MPNSClient; +import com.juick.components.MPNSManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,9 +9,10 @@ import org.springframework.context.annotation.Configuration; * Created by vital on 29.03.2017. */ @Configuration +@ConditionalOnProperty(name = "wns_application_sip") public class MPNSConfiguration { @Bean - public MPNSClient mpnsClient() { - return new MPNSClient(); + public MPNSManager mpnsClient() { + return new MPNSManager(); } } -- cgit v1.2.3