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}