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