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.io.Serializable; 019import java.time.ZonedDateTime; 020import java.util.ArrayList; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Map.Entry; 025import java.util.Set; 026import java.util.UUID; 027import java.util.concurrent.ConcurrentHashMap; 028import java.util.stream.Collectors; 029 030import org.apache.avalon.framework.component.Component; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.quartz.SchedulerException; 035 036import org.ametys.core.schedule.Runnable; 037import org.ametys.core.schedule.Runnable.FireProcess; 038import org.ametys.core.schedule.Runnable.MisfirePolicy; 039import org.ametys.core.user.CurrentUserProvider; 040import org.ametys.core.user.UserIdentity; 041import org.ametys.plugins.core.impl.schedule.DefaultRunnable; 042import org.ametys.plugins.core.schedule.Scheduler; 043import org.ametys.runtime.i18n.I18nizableText; 044import org.ametys.runtime.plugin.component.AbstractLogEnabled; 045 046import io.github.jav.exposerversdk.ExpoPushError; 047import io.github.jav.exposerversdk.ExpoPushMessage; 048import io.github.jav.exposerversdk.ExpoPushReceipt; 049import io.github.jav.exposerversdk.ExpoPushTicket; 050import io.github.jav.exposerversdk.PushClient; 051import io.github.jav.exposerversdk.PushClientCustomData; 052import io.github.jav.exposerversdk.PushClientException; 053import io.github.jav.exposerversdk.PushNotificationErrorsException; 054import io.github.jav.exposerversdk.PushNotificationException; 055import io.github.jav.exposerversdk.enums.ReceiptError; 056import io.github.jav.exposerversdk.enums.TicketError; 057 058/** 059 * Manager to push notifications to users 060 */ 061public class PushNotificationManager extends AbstractLogEnabled implements Serviceable, Component 062{ 063 /** Avalon Role */ 064 public static final String ROLE = PushNotificationManager.class.getName(); 065 066 /** Scheduler parameter name for tickets reference */ 067 public static final String TICKETS_REFERENCE = "ticketsReference"; 068 069 private Map<String, Map<String, TokenInfo>> _pendingTickets = new ConcurrentHashMap<>(); 070 private UserPreferencesHelper _userPreferencesHelper; 071 private Scheduler _scheduler; 072 private CurrentUserProvider _currentUserProvider; 073 074 public void service(ServiceManager manager) throws ServiceException 075 { 076 _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE); 077 _scheduler = (Scheduler) manager.lookup(Scheduler.ROLE); 078 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 079 } 080 081 /** 082 * Push a notification to all the registered devices of the users 083 * @param title title of the message 084 * @param message message to send 085 * @param users list users to notify 086 * @param data the data to push 087 * @throws PushClientException something went wrong creating the push client 088 * @throws SchedulerException an exception occurs setting up the scheduler for checking push receipts 089 */ 090 public void pushNotifications(String title, String message, List<UserIdentity> users, Map<String, Object> data) throws PushClientException, SchedulerException 091 { 092 Map<UserIdentity, Set<String>> tokens = new HashMap<>(); 093 for (UserIdentity user : users) 094 { 095 tokens.put(user, _userPreferencesHelper.getNotificationTokens(user)); 096 } 097 098 pushNotifications(title, message, tokens, data); 099 } 100 101 /** 102 * Push a notification to a list of registered devices 103 * @param title title of the message 104 * @param message message to send 105 * @param tokens list of device tokens 106 * @param data the data to push 107 * @throws PushClientException something went wrong creating the push client 108 * @throws SchedulerException an exception occurs setting up the scheduler for checking push receipts 109 */ 110 public void pushNotifications(String title, String message, Map<UserIdentity, Set<String>> tokens, Map<String, Object> data) throws PushClientException, SchedulerException 111 { 112 List<String> validTokens = tokens.values() 113 .stream() 114 .flatMap(Set::stream) 115 .filter(PushClientCustomData::isExponentPushToken) 116 .toList(); 117 118 if (validTokens.isEmpty()) 119 { 120 getLogger().debug("No valid token found, aborting push notification for message '{}'", title); 121 return; 122 } 123 124 // Invert tokens map, so we can easily retrieve the user from the token 125 Map<String, UserIdentity> tokenToUserMap = new HashMap<>(); 126 for (Entry<UserIdentity, Set<String>> entry : tokens.entrySet()) 127 { 128 for (String token : entry.getValue()) 129 { 130 tokenToUserMap.put(token, entry.getKey()); 131 } 132 } 133 134 PushClient client = new PushClient(); 135 136 List<TicketInfo> tickets = _pushNotifications(title, message, tokenToUserMap, data, client); 137 138 Map<String, String> recipientByTicket = new HashMap<>(); 139 140 for (TicketInfo ticketInfo : tickets) 141 { 142 ExpoPushTicket ticket = ticketInfo.ticket(); 143 String recipient = ticketInfo.token(); 144 switch (ticket.getStatus()) 145 { 146 case OK -> 147 { 148 recipientByTicket.put(ticket.getId(), recipient); 149 } 150 case ERROR -> 151 { 152 if (ticket.getDetails().getError() == TicketError.DEVICENOTREGISTERED) 153 { 154 String pushToken = (String) ticket.getDetails().getAdditionalProperties().get("expoPushToken"); 155 156 // double check to be sure 157 if (recipient.equals(pushToken)) 158 { 159 UserIdentity user = tokenToUserMap.get(pushToken); 160 if (user != null) 161 { 162 getLogger().info("Remove unregistered token {} for user {}", pushToken, user); 163 _userPreferencesHelper.removeNotificationToken(pushToken, user); 164 } 165 else 166 { 167 // should not happen 168 getLogger().warn("Received unregistered token {} but cannot find associated user", pushToken); 169 } 170 } 171 } 172 else 173 { 174 getLogger().error("Received push error '{}': ", ticket.getDetails().getError(), ticket.getMessage()); 175 } 176 } 177 default -> 178 { 179 throw new IllegalArgumentException("Unknown status for push ticket: " + ticket.getStatus()); 180 } 181 } 182 } 183 184 getLogger().info("Got successfully sent push tickets: {}", recipientByTicket); 185 186 // Expo work is done, but we have to check in a few minutes if messages have been actually delivered by Apple and Google 187 188 Map<String, TokenInfo> tokenInfos = recipientByTicket.entrySet() 189 .stream() 190 .collect(Collectors.toMap(Entry::getKey, 191 e -> new TokenInfo(e.getValue(), 192 UserIdentity.userIdentityToString(tokenToUserMap.get(e.getValue()))))); 193 194 if (tokenInfos.isEmpty()) 195 { 196 getLogger().debug("No tickets to check receipts for, skipping scheduling."); 197 return; 198 } 199 200 String ticketsReference = UUID.randomUUID().toString(); 201 _pendingTickets.put(ticketsReference, tokenInfos); 202 203 ZonedDateTime dateTime = ZonedDateTime.now().plusMinutes(15); // Expo recommends to check receipts after 15 minutes 204 Runnable runnable = new DefaultRunnable(getClass().getName() + "$" + ticketsReference, 205 new I18nizableText("plugin.mobileapp", "PLUGINS_MOBILEAPP_PUSH_RUNNABLE_LABEL"), 206 new I18nizableText("plugin.mobileapp", "PLUGINS_MOBILEAPP_PUSH_RUNNABLE_DESCRIPTION"), 207 FireProcess.CRON, 208 dateTime.getSecond() + " " + dateTime.getMinute() + " " + dateTime.getHour() + " " + dateTime.getDayOfMonth() + " " + dateTime.getMonthValue() + " ? " + dateTime.getYear(), 209 "org.ametys.plugins.mobileapp.push.schedule.PushReceipts", 210 true /* removable */, 211 false /* modifiable */, 212 false /* deactivatable */, 213 MisfirePolicy.FIRE_ONCE, 214 true /* isVolatile */, // useless to persist it, expo tickets are themselves volatile 215 _currentUserProvider.getUser(), 216 Map.of(TICKETS_REFERENCE, ticketsReference)); 217 218 _scheduler.scheduleJob(runnable); 219 } 220 221 @SuppressWarnings("unchecked") 222 private List<TicketInfo> _pushNotifications(String title, String message, Map<String, UserIdentity> tokens, Map<String, Object> data, PushClient client) 223 { 224 getLogger().info("Pushing message '{}' with tokens {}", title, tokens); 225 226 // Build the expo push message 227 ExpoPushMessage expoPushMessage = new ExpoPushMessage(); 228 expoPushMessage.addAllTo(List.copyOf(tokens.keySet())); 229 expoPushMessage.setTitle(title); 230 expoPushMessage.setBody(message); 231 232 expoPushMessage.setData(data); 233 234 // Chunk the messages, each chunk will be sent in a single request 235 List<List<ExpoPushMessage>> chunks = client.chunkPushNotifications(List.of(expoPushMessage)); 236 237 List<TicketInfo> allTickets = new ArrayList<>(); 238 for (List<ExpoPushMessage> chunk : chunks) 239 { 240 try 241 { 242 // Wait for each completable future to finish 243 List<ExpoPushTicket> tickets = client.sendPushNotificationsAsync(chunk).join(); 244 245 // At this point, we should have as many recipients than received tickets 246 List<String> recipients = chunk.stream() 247 .map(ExpoPushMessage::getTo) 248 .flatMap(List::stream) 249 .toList(); 250 251 if (tickets.size() == recipients.size()) 252 { 253 for (int i = 0; i < tickets.size(); i++) 254 { 255 allTickets.add(new TicketInfo(recipients.get(i), tickets.get(i))); 256 } 257 } 258 else 259 { 260 getLogger().warn("Number of push tickets {} is different from number of recipients {}, something went wrong", tickets.size(), recipients.size()); 261 } 262 } 263 catch (PushNotificationException e) 264 { 265 if (e.exception instanceof PushNotificationErrorsException ex) 266 { 267 for (ExpoPushError error : ex.errors) 268 { 269 if ("PUSH_TOO_MANY_EXPERIENCE_IDS".equals(error.getCode())) 270 { 271 StringBuilder errorMessage = new StringBuilder("Push message " + title + " failed because tokens are associated with more than one expo project. Message will be split and sent again but you may want to remove wrong tokens:"); 272 273 List<Map<String, UserIdentity>> tokensByProject = new ArrayList<>(); 274 275 Map<String, Object> details = (Map<String, Object>) error.getAdditionalProperties().get("details"); 276 for (String projectSlug : details.keySet()) 277 { 278 errorMessage.append("\n\nFollowing tokens are associated with project ").append(projectSlug).append(':'); 279 List<String> expoTokens = (List<String>) details.get(projectSlug); 280 281 Map<String, UserIdentity> tokenForProject = new HashMap<>(); 282 tokensByProject.add(tokenForProject); 283 for (String expoToken : expoTokens) 284 { 285 UserIdentity userIdentity = tokens.get(expoToken); 286 errorMessage.append('\n').append(expoToken).append(" for user ").append(UserIdentity.userIdentityToString(userIdentity)); 287 tokenForProject.put(expoToken, userIdentity); 288 } 289 } 290 291 getLogger().error(errorMessage.toString()); 292 293 for (Map<String, UserIdentity> tokenForProject : tokensByProject) 294 { 295 allTickets.addAll(_pushNotifications(title, message, tokenForProject, data, client)); 296 } 297 } 298 else 299 { 300 getLogger().error("Push notification {} returned an error with code '{}': {}", title, error.getCode(), error.getMessage(), ex); 301 } 302 } 303 } 304 else 305 { 306 getLogger().error("Exception while sending push notification {}", title, e); 307 } 308 } 309 } 310 311 return allTickets; 312 } 313 314 /** 315 * Check the status of previously sent push notifications by their in-memory reference 316 * @param reference the reference of tickets to check 317 * @throws Exception something went wrong while checking the tickets 318 */ 319 public void checkTickets(String reference) throws Exception 320 { 321 Map<String, TokenInfo> tickets = _pendingTickets.remove(reference); 322 if (tickets == null) 323 { 324 getLogger().warn("No pending tickets found in memory for reference '{}' (the server may have been restarted). Push receipts check will be skipped.", reference); 325 return; 326 } 327 328 if (tickets.isEmpty()) 329 { 330 getLogger().debug("No ticket to check"); 331 return; 332 } 333 334 getLogger().info("Checking tickets {}", tickets); 335 336 PushClient client = new PushClient(); 337 338 // Chunk the messages, each chunk will be sent in a single request 339 List<List<String>> chunks = client.chunkPushNotificationReceiptIds(List.copyOf(tickets.keySet())); 340 341 for (List<String> chunk : chunks) 342 { 343 try 344 { 345 List<ExpoPushReceipt> receipts = client.getPushNotificationReceiptsAsync(chunk).join(); 346 347 for (ExpoPushReceipt receipt : receipts) 348 { 349 if (receipt.getStatus() == io.github.jav.exposerversdk.enums.Status.ERROR && receipt.getDetails().getError() == ReceiptError.DEVICENOTREGISTERED) 350 { 351 TokenInfo tokenInfo = tickets.get(receipt.getId()); 352 353 if (tokenInfo != null) 354 { 355 String user = tokenInfo.user(); 356 String token = tokenInfo.token(); 357 358 getLogger().warn("Device not registered for token {} associated with user {}, it will be removed", token, user); 359 360 UserIdentity userIdentity = UserIdentity.stringToUserIdentity(user); 361 _userPreferencesHelper.removeNotificationToken(token, userIdentity); 362 } 363 } 364 } 365 } 366 catch (PushNotificationException e) 367 { 368 getLogger().error("Exception while getting push receipts", e); 369 } 370 } 371 } 372 373 private record TicketInfo(String token, ExpoPushTicket ticket) { /* empty */ } 374 375 /** 376 * Association between an expo token and its associated user 377 * @param token the Expo token 378 * @param user the user identity as a string to be serializable 379 */ 380 public record TokenInfo(String token, String user) implements Serializable { /* empty */ } 381}