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.core.user.UserIdentity; 035import org.ametys.runtime.plugin.component.AbstractLogEnabled; 036 037import io.github.jav.exposerversdk.ExpoPushError; 038import io.github.jav.exposerversdk.ExpoPushMessage; 039import io.github.jav.exposerversdk.ExpoPushMessageTicketPair; 040import io.github.jav.exposerversdk.ExpoPushTicket; 041import io.github.jav.exposerversdk.PushClient; 042import io.github.jav.exposerversdk.PushClientCustomData; 043import io.github.jav.exposerversdk.PushClientException; 044import io.github.jav.exposerversdk.PushNotificationErrorsException; 045import io.github.jav.exposerversdk.PushNotificationException; 046 047/** 048 * manager to push notifications to users 049 */ 050public class PushNotificationManager extends AbstractLogEnabled implements Serviceable, Component 051{ 052 /** Avalon Role */ 053 public static final String ROLE = PushNotificationManager.class.getName(); 054 055 /** User Preferences Helper */ 056 protected UserPreferencesHelper _userPreferencesHelper; 057 058 public void service(ServiceManager manager) throws ServiceException 059 { 060 _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE); 061 } 062 063 /** 064 * Push a notification to a list of registered devices 065 * @param title title of the message 066 * @param message message to send 067 * @param tokens list of devicet tokens 068 * @param data the data to push 069 * @throws PushClientException something went wrong 070 */ 071 public void pushNotifications(String title, String message, Map<UserIdentity, Set<String>> tokens, Map<String, Object> data) throws PushClientException 072 { 073 getLogger().debug("Pushing message '{}' with tokens {}", title, tokens); 074 075 // if something is missing : https://github.com/jav/expo-server-sdk-java 076 List<String> validTokens = tokens.values() 077 .stream() 078 .flatMap(Set::stream) 079 .filter(PushClientCustomData::isExponentPushToken) 080 .toList(); 081 082 if (validTokens.size() > 0) 083 { 084 ExpoPushMessage expoPushMessage = new ExpoPushMessage(); 085 expoPushMessage.addAllTo(validTokens); 086 expoPushMessage.setTitle(title); 087 expoPushMessage.setBody(message); 088 089 expoPushMessage.setData(data); 090 091 092 List<ExpoPushMessage> expoPushMessages = new ArrayList<>(); 093 expoPushMessages.add(expoPushMessage); 094 095 PushClient client = new PushClient(); 096 List<List<ExpoPushMessage>> chunks = client.chunkPushNotifications(expoPushMessages); 097 098 List<CompletableFuture<List<ExpoPushTicket>>> messageRepliesFutures = new ArrayList<>(); 099 100 for (List<ExpoPushMessage> chunk : chunks) 101 { 102 messageRepliesFutures.add(client.sendPushNotificationsAsync(chunk)); 103 } 104 105 // Invert map, so we can easily retrieve the user from the token 106 Map<String, UserIdentity> tokenToUserMap = new HashMap<>(); 107 for (Entry<UserIdentity, Set<String>> entry : tokens.entrySet()) 108 { 109 for (String token : entry.getValue()) 110 { 111 tokenToUserMap.put(token, entry.getKey()); 112 } 113 } 114 115 // Wait for each completable future to finish 116 List<ExpoPushTicket> allTickets = new ArrayList<>(); 117 List<ExpoPushMessage> expoPushMessagesHandled = new ArrayList<>(); 118 int count = 0; 119 for (CompletableFuture<List<ExpoPushTicket>> messageReplyFuture : messageRepliesFutures) 120 { 121 try 122 { 123 for (ExpoPushTicket ticket : messageReplyFuture.get()) 124 { 125 if (count < expoPushMessages.size()) 126 { 127 expoPushMessagesHandled.add(expoPushMessages.get(count)); 128 allTickets.add(ticket); 129 count++; 130 } 131 else 132 { 133 getLogger().warn("More tickets were received than sent, this is strange."); 134 } 135 } 136 } 137 catch (InterruptedException | ExecutionException e) 138 { 139 if (e.getCause() instanceof PushNotificationException) 140 { 141 _handlePushError((PushNotificationException) e.getCause(), title, message, data, tokenToUserMap); 142 } 143 else 144 { 145 getLogger().warn("Error while sending push notification", e); 146 } 147 } 148 } 149 150 List<ExpoPushMessageTicketPair<ExpoPushMessage>> zippedMessagesTickets = client.zipMessagesTickets(expoPushMessagesHandled, allTickets); 151 152 List<ExpoPushMessageTicketPair<ExpoPushMessage>> okTicketMessages = client.filterAllSuccessfulMessages(zippedMessagesTickets); 153 String okTicketMessagesString = okTicketMessages.stream().map( 154 p -> "Title: " + p.message.getTitle() + ", Id:" + p.ticket.getId() 155 ).collect(Collectors.joining(",")); 156 157 if (!okTicketMessages.isEmpty()) 158 { 159 getLogger().info( 160 "Recieved OK ticket for " 161 + okTicketMessages.size() 162 + " messages: " + okTicketMessagesString 163 ); 164 } 165 166 List<ExpoPushMessageTicketPair<ExpoPushMessage>> errorTicketMessages = client.filterAllMessagesWithError(zippedMessagesTickets); 167 String errorTicketMessagesString = errorTicketMessages.stream().map( 168 p -> "Title: " + p.message.getTitle() + ", Error: " + p.ticket.getDetails().getError() 169 ).collect(Collectors.joining(",")); 170 171 if (!errorTicketMessages.isEmpty()) 172 { 173 getLogger().warn("Received ERROR push ticket for {} messages: {}", errorTicketMessages.size(), errorTicketMessagesString); 174 175 for (ExpoPushMessageTicketPair<ExpoPushMessage> errorTicketMessage : errorTicketMessages) 176 { 177 String pushToken = (String) errorTicketMessage.ticket.getDetails().getAdditionalProperties().get("expoPushToken"); 178 179 UserIdentity user = tokenToUserMap.get(pushToken); 180 181 getLogger().debug("remove token {} for user {} messages: {}", pushToken, user, errorTicketMessagesString); 182 183 _userPreferencesHelper.removeNotificationToken(pushToken, user); 184 } 185 } 186 } 187 } 188 189 /** 190 * Handle an exception essentially when trying to push notifications to multiple expo projects, to do it again for each project 191 * @param exception the exception thrown 192 * @param title title of the notification 193 * @param message description of the notification 194 * @param data data of the notification 195 * @param tokenToUserMap Reversed user/token map 196 * @throws PushClientException Something went very wrong 197 */ 198 protected void _handlePushError(PushNotificationException exception, String title, String message, Map<String, Object> data, Map<String, UserIdentity> tokenToUserMap) throws PushClientException 199 { 200 Map<String, Set<String>> projects = new HashMap<>(); 201 Exception cause = exception.exception; 202 if (cause instanceof PushNotificationErrorsException) 203 { 204 PushNotificationErrorsException error = (PushNotificationErrorsException) cause; 205 for (ExpoPushError expoPushError : error.errors) 206 { 207 Map<String, Object> additionalProperties = expoPushError.getAdditionalProperties(); 208 if (additionalProperties != null) 209 { 210 Object details = additionalProperties.get("details"); 211 if (details != null && details instanceof Map) 212 { 213 @SuppressWarnings("unchecked") 214 Map<String, List<String>> detailsMap = (Map<String, List<String>>) details; 215 for (Entry<String, List<String>> entry : detailsMap.entrySet()) 216 { 217 if (!projects.containsKey(entry.getKey())) 218 { 219 projects.put(entry.getKey(), new HashSet<>()); 220 } 221 projects.get(entry.getKey()).addAll(entry.getValue()); 222 } 223 } 224 } 225 } 226 227 } 228 229 for (Entry<String, Set<String>> entry : projects.entrySet()) 230 { 231 getLogger().warn("Trying to send push notification to multiple expo projects, one of them is {}, containing {} tokens.", entry.getKey(), entry.getValue().size()); 232 Map<UserIdentity, Set<String>> tokensForOneProject = new HashMap<>(); 233 for (String token : entry.getValue()) 234 { 235 UserIdentity user = tokenToUserMap.get(token); 236 tokensForOneProject.computeIfAbsent(user, u -> new HashSet<>()).add(token); 237 } 238 pushNotifications(title, message, tokensForOneProject, data); 239 } 240 } 241}