001/* 002 * Copyright 2020 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.mobileapp; 017 018import java.util.ArrayList; 019import java.util.HashMap; 020import java.util.HashSet; 021import java.util.List; 022import java.util.Map; 023import java.util.Map.Entry; 024import java.util.Set; 025import java.util.concurrent.CompletableFuture; 026import java.util.concurrent.ExecutionException; 027import java.util.stream.Collectors; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.service.ServiceException; 031import org.apache.avalon.framework.service.ServiceManager; 032import org.apache.avalon.framework.service.Serviceable; 033 034import org.ametys.runtime.plugin.component.AbstractLogEnabled; 035 036import io.github.jav.exposerversdk.ExpoPushError; 037import io.github.jav.exposerversdk.ExpoPushMessage; 038import io.github.jav.exposerversdk.ExpoPushMessageTicketPair; 039import io.github.jav.exposerversdk.ExpoPushTicket; 040import io.github.jav.exposerversdk.PushClient; 041import io.github.jav.exposerversdk.PushClientCustomData; 042import io.github.jav.exposerversdk.PushClientException; 043import io.github.jav.exposerversdk.PushNotificationErrorsException; 044import io.github.jav.exposerversdk.PushNotificationException; 045 046/** 047 * manager to push notifications to users 048 */ 049public class PushNotificationManager extends AbstractLogEnabled implements Serviceable, Component 050{ 051 /** Avalon Role */ 052 public static final String ROLE = PushNotificationManager.class.getName(); 053 054 /** User Preferences Helper */ 055 protected UserPreferencesHelper _userPreferencesHelper; 056 057 public void service(ServiceManager manager) throws ServiceException 058 { 059 _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE); 060 } 061 062 /** 063 * Push a notification to a list of registered devices 064 * @param title title of the message 065 * @param message message to send 066 * @param tokens list of devicet tokens 067 * @param data the data to push 068 * @throws PushClientException something went wrong 069 */ 070 // TODO Change the API to have the users for each tokens, to be able to clean them 071 public void pushNotifications(String title, String message, Set<String> tokens, Map<String, Object> data) throws PushClientException 072 { 073 // if something is missing : https://github.com/jav/expo-server-sdk-java 074 List<String> validTokens = tokens.stream() 075 .filter(PushClientCustomData::isExponentPushToken) 076 .collect(Collectors.toList()); 077 078 if (validTokens.size() > 0) 079 { 080 ExpoPushMessage expoPushMessage = new ExpoPushMessage(); 081 expoPushMessage.addAllTo(validTokens); 082 expoPushMessage.setTitle(title); 083 expoPushMessage.setBody(message); 084 expoPushMessage.setData(data); 085 086 getLogger().debug("Pushing message '{}' for tokens {}", title, validTokens); 087 088 List<ExpoPushMessage> expoPushMessages = new ArrayList<>(); 089 expoPushMessages.add(expoPushMessage); 090 091 PushClient client = new PushClient(); 092 List<List<ExpoPushMessage>> chunks = client.chunkPushNotifications(expoPushMessages); 093 094 List<CompletableFuture<List<ExpoPushTicket>>> messageRepliesFutures = new ArrayList<>(); 095 096 for (List<ExpoPushMessage> chunk : chunks) 097 { 098 messageRepliesFutures.add(client.sendPushNotificationsAsync(chunk)); 099 } 100 101 // Wait for each completable future to finish 102 List<ExpoPushTicket> allTickets = new ArrayList<>(); 103 List<ExpoPushMessage> expoPushMessagesHandled = new ArrayList<>(); 104 int count = 0; 105 for (CompletableFuture<List<ExpoPushTicket>> messageReplyFuture : messageRepliesFutures) 106 { 107 try 108 { 109 for (ExpoPushTicket ticket : messageReplyFuture.get()) 110 { 111 if (count < expoPushMessages.size()) 112 { 113 expoPushMessagesHandled.add(expoPushMessages.get(count)); 114 allTickets.add(ticket); 115 count++; 116 } 117 else 118 { 119 getLogger().warn("More tickets were received than sent, this is strange."); 120 } 121 } 122 } 123 catch (InterruptedException | ExecutionException e) 124 { 125 if (e.getCause() instanceof PushNotificationException) 126 { 127 _handlePushError((PushNotificationException) e.getCause(), title, message, data); 128 } 129 else 130 { 131 getLogger().warn("Error while sending push notification", e); 132 } 133 } 134 } 135 136 List<ExpoPushMessageTicketPair<ExpoPushMessage>> zippedMessagesTickets = client.zipMessagesTickets(expoPushMessagesHandled, allTickets); 137 138 List<ExpoPushMessageTicketPair<ExpoPushMessage>> okTicketMessages = client.filterAllSuccessfulMessages(zippedMessagesTickets); 139 String okTicketMessagesString = okTicketMessages.stream().map( 140 p -> "Title: " + p.message.getTitle() + ", Id:" + p.ticket.getId() 141 ).collect(Collectors.joining(",")); 142 143 if (!okTicketMessages.isEmpty()) 144 { 145 getLogger().info( 146 "Recieved OK ticket for " 147 + okTicketMessages.size() 148 + " messages: " + okTicketMessagesString 149 ); 150 } 151 152 List<ExpoPushMessageTicketPair<ExpoPushMessage>> errorTicketMessages = client.filterAllMessagesWithError(zippedMessagesTickets); 153 String errorTicketMessagesString = errorTicketMessages.stream().map( 154 p -> "Title: " + p.message.getTitle() + ", Error: " + p.ticket.getDetails().getError() 155 ).collect(Collectors.joining(",")); 156 157 if (!errorTicketMessages.isEmpty()) 158 { 159 // TODO Verify here to disable erronous tokens 160 getLogger().warn( 161 "Recieved ERROR push ticket for " 162 + errorTicketMessages.size() 163 + " messages: " 164 + errorTicketMessagesString 165 ); 166 } 167 // TODO verify the count of success and error tokens, invalid tokens (not the valid ones with no device) are not returned, and they should be disabled. 168 } 169 } 170 171 /** 172 * Handle an exception essentially when trying to push notifications to multiple projects, to do it again for each project 173 * @param exception the exception thrown 174 * @param title title of the notification 175 * @param message description of the notification 176 * @param data data of the notification 177 * @throws PushClientException Something went very wrong 178 */ 179 protected void _handlePushError(PushNotificationException exception, String title, String message, Map<String, Object> data) throws PushClientException 180 { 181 Map<String, Set<String>> projects = new HashMap<>(); 182 Exception cause = exception.exception; 183 if (cause instanceof PushNotificationErrorsException) 184 { 185 PushNotificationErrorsException error = (PushNotificationErrorsException) cause; 186 for (ExpoPushError expoPushError : error.errors) 187 { 188 Map<String, Object> additionalProperties = expoPushError.getAdditionalProperties(); 189 if (additionalProperties != null) 190 { 191 Object details = additionalProperties.get("details"); 192 if (details != null && details instanceof Map) 193 { 194 @SuppressWarnings("unchecked") 195 Map<String, List<String>> detailsMap = (Map<String, List<String>>) details; 196 for (Entry<String, List<String>> entry : detailsMap.entrySet()) 197 { 198 if (!projects.containsKey(entry.getKey())) 199 { 200 projects.put(entry.getKey(), new HashSet<>()); 201 } 202 projects.get(entry.getKey()).addAll(entry.getValue()); 203 } 204 } 205 } 206 } 207 208 } 209 210 for (Entry<String, Set<String>> entry : projects.entrySet()) 211 { 212 getLogger().warn("Trying to send push notification to multiple projects, one of them is {}, containing {} tokens.", entry.getKey(), entry.getValue().size()); 213 pushNotifications(title, message, entry.getValue(), data); 214 } 215 } 216 217}