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