/*
* 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.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.android.gcm.server.*;
import com.juick.components.mpns.MPNSError;
import com.juick.components.mpns.MPNSToken;
import com.notnoop.apns.APNS;
import com.notnoop.apns.ApnsService;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.http.*;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.apache.http.util.TextUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import rocks.xmpp.core.XmppException;
import rocks.xmpp.core.session.Extension;
import rocks.xmpp.core.session.XmppSessionConfiguration;
import rocks.xmpp.extensions.component.accept.ExternalComponent;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
/**
* @author Ugnich Anton
*/
public class Notifications implements AutoCloseable {
private static Logger logger = LoggerFactory.getLogger(Notifications.class);
private final RestTemplate rest;
private ExternalComponent xmpp;
private final Sender GCMSender;
private final String wns_application_sip;
private final String wns_client_secret;
private final String pushJid;
private final String xmppHost;
private final int xmppPort;
private final String xmppPushPassword;
private final ObjectMapper mapper;
private final Set invalidGCMTokens;
private final Set invalidMPNSTokens;
@Inject
private ApnsService apns;
public Notifications(final Environment env, final RestTemplate rest) {
this.rest = rest;
wns_application_sip = env.getProperty("wns_application_sip", StringUtils.EMPTY);
wns_client_secret = env.getProperty("wns_client_secret", StringUtils.EMPTY);
GCMSender = new Sender(env.getProperty("gcm_key", StringUtils.EMPTY), Endpoint.GCM);
pushJid = env.getProperty("push_jid");
xmppHost = env.getProperty("xmpp_host", "localhost");
xmppPort = NumberUtils.toInt(env.getProperty("xmpp_port"), 5347);
xmppPushPassword = env.getProperty("push_xmpp_password", StringUtils.EMPTY);
mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT);
invalidGCMTokens = Collections.synchronizedSet(new HashSet<>());
invalidMPNSTokens = Collections.synchronizedSet(new HashSet<>());
}
@PostConstruct
public void init() {
XmppSessionConfiguration configuration = XmppSessionConfiguration.builder()
.extensions(Extension.of(com.juick.Message.class))
.build();
xmpp = ExternalComponent.create(pushJid, xmppPushPassword, configuration, xmppHost, xmppPort);
xmpp.addInboundMessageListener(e -> {
rocks.xmpp.core.stanza.model.Message msg = e.getMessage();
com.juick.Message jmsg = msg.getExtension(com.juick.Message.class);
boolean isPM = jmsg.getMid() == 0;
boolean isReply = jmsg.getRid() > 0;
int pmTo = NumberUtils.toInt(msg.getTo().getLocal(), 0);
/*** ANDROID ***/
final List regids = new ArrayList<>();
if (isPM) {
regids.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=gcm&uid=%d",
pmTo),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
} else {
regids.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=gcm&uid=%s&mid=%s",
jmsg.getUser().getUid(), jmsg.getMid()),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
}
if (!regids.isEmpty()) {
try {
ObjectMapper messageSerializer = new ObjectMapper();
messageSerializer.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
messageSerializer.setSerializationInclusion(JsonInclude.Include.NON_NULL);
messageSerializer.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT);
String json = messageSerializer.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 ***/
final List urls = new ArrayList<>();
if (isPM) {
urls.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=mpns&uid=%s",
pmTo),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
} else {
urls.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=mpns&uid=%s&mid=%s",
jmsg.getUser().getUid(), jmsg.getMid()),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
}
if (urls.isEmpty()) {
logger.info("WNS: no recipients");
} else {
try {
String wnsToken = getWnsAccessToken();
String text1 = "@" + jmsg.getUser().getName();
if (!jmsg.getTags().isEmpty()) {
text1 += ":" + StringEscapeUtils.escapeXml11(jmsg.getTagsString());
}
String text2 = StringEscapeUtils.escapeXml11(jmsg.getText());
String xml = ""
+ ""
+ ""
+ ""
+ ""
+ "" + text1 + ""
+ "" + text2 + ""
+ ""
+ ""
+ ""
+ ""
+ ""
+ "";
logger.trace(xml);
for (String url : urls) {
logger.info("WNS: {}", url);
sendWNS(wnsToken, url, xml);
}
} catch (IOException | IllegalStateException ex) {
logger.error("WNS: ", ex);
}
}
/*** iOS ***/
final List tokens = new ArrayList<>();
if (isPM) {
tokens.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=apns&uid=%s",
pmTo),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
} else {
tokens.addAll(rest.exchange(String.format("http://api.juick.com/notifications?type=apns&uid=%s&mid=%s",
jmsg.getUser().getUid(), jmsg.getMid()),
HttpMethod.GET, null, new ParameterizedTypeReference>() {
}).getBody());
}
if (!tokens.isEmpty()) {
for (String token : tokens) {
String payload = APNS.newPayload().alertTitle("@" + jmsg.getUser().getName()).alertBody(jmsg.getText()).build();
logger.info("APNS: {}", token);
apns.push(token, payload);
}
} else {
logger.info("APNS: no recipients");
}
});
try {
xmpp.connect();
} catch (XmppException e) {
logger.warn("xmpp extension", e);
}
}
@Override
public void close() throws Exception {
if (xmpp != null)
xmpp.close();
logger.info("ExternalComponent on notifications destroyed");
}
String getWnsAccessToken() throws IOException, IllegalStateException {
if (TextUtils.isEmpty(wns_application_sip)) {
throw new IllegalStateException("'wns_application_sip' is not initialized");
}
if (TextUtils.isEmpty(wns_client_secret)) {
throw new IllegalStateException("'wns_client_secret' is not initialized");
}
HttpClient client = HttpClientBuilder.create().build();
String url = "https://login.live.com/accesstoken.srf";
List formParams = new ArrayList<>();
formParams.add(new BasicNameValuePair("grant_type", "client_credentials"));
formParams.add(new BasicNameValuePair("client_id", wns_application_sip));
formParams.add(new BasicNameValuePair("client_secret", wns_client_secret));
formParams.add(new BasicNameValuePair("scope", "notify.windows.com"));
UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formParams, Consts.UTF_8);
HttpPost httppost = new HttpPost(url);
httppost.setEntity(entity);
HttpResponse response = client.execute(httppost);
int statusCode = response.getStatusLine().getStatusCode();
String responseContent = EntityUtils.toString(response.getEntity(), Consts.UTF_8);
if (statusCode != HttpStatus.SC_OK) {
MPNSError error = mapper.readValue(responseContent, MPNSError.class);
throw new IOException(error.getError() + ": " + error.getErrorDescription());
}
MPNSToken token = mapper.readValue(responseContent, MPNSToken.class);
if (token.getTokenType().length() >= 1) {
token.setTokenType(Character.toUpperCase(token.getTokenType().charAt(0)) + token.getTokenType().substring(1));
}
return token.getTokenType() + " " + token.getAccessToken();
}
void sendWNS(final String wnsToken, final String url, final String xml) throws IOException {
HttpClient client = HttpClientBuilder.create().build();
StringEntity entity = new StringEntity(xml, Consts.UTF_8);
HttpPost httpPost = new HttpPost(url);
httpPost.setHeader("Content-Type", "text/xml");
httpPost.setHeader("Authorization", wnsToken);
httpPost.setHeader("X-WNS-Type", "wns/toast");
httpPost.setEntity(entity);
HttpResponse response = client.execute(httpPost);
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != HttpStatus.SC_OK) {
if (statusCode == HttpStatus.SC_GONE) {
// expired
logger.info("{} is scheduled to remove", url);
addInvalidMPNSToken(url);
} else {
String headersContent = stringifyWnsHttpHeaders(response.getAllHeaders());
throw new IOException(headersContent);
}
}
}
static String stringifyWnsHttpHeaders(final Header[] allHeaders) {
return Arrays.stream(allHeaders)
.filter(x -> x.getName().startsWith("X-WNS-") || x.getName().startsWith("WWW-"))
.map(x -> x.getName() + ": " + x.getValue())
.collect(Collectors.joining("\n"));
}
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();
}
}
}