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}