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.util.ArrayList;
019import java.util.HashMap;
020import java.util.HashSet;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.Set;
025import java.util.concurrent.CompletableFuture;
026import java.util.concurrent.ExecutionException;
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;
033
034import org.ametys.core.user.UserIdentity;
035import org.ametys.runtime.plugin.component.AbstractLogEnabled;
036
037import io.github.jav.exposerversdk.ExpoPushError;
038import io.github.jav.exposerversdk.ExpoPushMessage;
039import io.github.jav.exposerversdk.ExpoPushMessageTicketPair;
040import io.github.jav.exposerversdk.ExpoPushTicket;
041import io.github.jav.exposerversdk.PushClient;
042import io.github.jav.exposerversdk.PushClientCustomData;
043import io.github.jav.exposerversdk.PushClientException;
044import io.github.jav.exposerversdk.PushNotificationErrorsException;
045import io.github.jav.exposerversdk.PushNotificationException;
046
047/**
048 * manager to push notifications to users
049 */
050public class PushNotificationManager extends AbstractLogEnabled implements Serviceable, Component
051{
052    /** Avalon Role */
053    public static final String ROLE = PushNotificationManager.class.getName();
054
055    /** User Preferences Helper */
056    protected UserPreferencesHelper _userPreferencesHelper;
057
058    public void service(ServiceManager manager) throws ServiceException
059    {
060        _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE);
061    }
062
063    /**
064     * Push a notification to a list of registered devices
065     * @param title title of the message
066     * @param message message to send
067     * @param tokens list of devicet tokens
068     * @param data the data to push
069     * @throws PushClientException something went wrong
070     */
071    public void pushNotifications(String title, String message, Map<UserIdentity, Set<String>> tokens, Map<String, Object> data) throws PushClientException
072    {
073        getLogger().debug("Pushing message '{}' with tokens {}", title, tokens);
074
075        // if something is missing : https://github.com/jav/expo-server-sdk-java
076        List<String> validTokens = tokens.values()
077                .stream()
078                .flatMap(Set::stream)
079                .filter(PushClientCustomData::isExponentPushToken)
080                .toList();
081
082        if (validTokens.size() > 0)
083        {
084            ExpoPushMessage expoPushMessage = new ExpoPushMessage();
085            expoPushMessage.addAllTo(validTokens);
086            expoPushMessage.setTitle(title);
087            expoPushMessage.setBody(message);
088
089            expoPushMessage.setData(data);
090
091
092            List<ExpoPushMessage> expoPushMessages = new ArrayList<>();
093            expoPushMessages.add(expoPushMessage);
094
095            PushClient client = new PushClient();
096            List<List<ExpoPushMessage>> chunks = client.chunkPushNotifications(expoPushMessages);
097
098            List<CompletableFuture<List<ExpoPushTicket>>> messageRepliesFutures = new ArrayList<>();
099
100            for (List<ExpoPushMessage> chunk : chunks)
101            {
102                messageRepliesFutures.add(client.sendPushNotificationsAsync(chunk));
103            }
104            
105            // Invert map, so we can easily retrieve the user from the token
106            Map<String, UserIdentity> tokenToUserMap = new HashMap<>();
107            for (Entry<UserIdentity, Set<String>> entry : tokens.entrySet())
108            {
109                for (String token : entry.getValue())
110                {
111                    tokenToUserMap.put(token, entry.getKey());
112                }
113            }
114
115            // Wait for each completable future to finish
116            List<ExpoPushTicket> allTickets = new ArrayList<>();
117            List<ExpoPushMessage> expoPushMessagesHandled = new ArrayList<>();
118            int count = 0;
119            for (CompletableFuture<List<ExpoPushTicket>> messageReplyFuture : messageRepliesFutures)
120            {
121                try
122                {
123                    for (ExpoPushTicket ticket : messageReplyFuture.get())
124                    {
125                        if (count < expoPushMessages.size())
126                        {
127                            expoPushMessagesHandled.add(expoPushMessages.get(count));
128                            allTickets.add(ticket);
129                            count++;
130                        }
131                        else
132                        {
133                            getLogger().warn("More tickets were received than sent, this is strange.");
134                        }
135                    }
136                }
137                catch (InterruptedException | ExecutionException e)
138                {
139                    if (e.getCause() instanceof PushNotificationException)
140                    {
141                        _handlePushError((PushNotificationException) e.getCause(), title, message, data, tokenToUserMap);
142                    }
143                    else
144                    {
145                        getLogger().warn("Error while sending push notification", e);
146                    }
147                }
148            }
149
150            List<ExpoPushMessageTicketPair<ExpoPushMessage>> zippedMessagesTickets = client.zipMessagesTickets(expoPushMessagesHandled, allTickets);
151
152            List<ExpoPushMessageTicketPair<ExpoPushMessage>> okTicketMessages = client.filterAllSuccessfulMessages(zippedMessagesTickets);
153            String okTicketMessagesString = okTicketMessages.stream().map(
154                p -> "Title: " + p.message.getTitle() + ", Id:" + p.ticket.getId()
155            ).collect(Collectors.joining(","));
156
157            if (!okTicketMessages.isEmpty())
158            {
159                getLogger().info(
160                        "Recieved OK ticket for "
161                                + okTicketMessages.size()
162                                + " messages: " + okTicketMessagesString
163                );
164            }
165
166            List<ExpoPushMessageTicketPair<ExpoPushMessage>> errorTicketMessages = client.filterAllMessagesWithError(zippedMessagesTickets);
167            String errorTicketMessagesString = errorTicketMessages.stream().map(
168                p -> "Title: " + p.message.getTitle() + ", Error: " + p.ticket.getDetails().getError()
169            ).collect(Collectors.joining(","));
170
171            if (!errorTicketMessages.isEmpty())
172            {
173                getLogger().warn("Received ERROR push ticket for {} messages: {}", errorTicketMessages.size(), errorTicketMessagesString);
174
175                for (ExpoPushMessageTicketPair<ExpoPushMessage> errorTicketMessage : errorTicketMessages)
176                {
177                    String pushToken = (String) errorTicketMessage.ticket.getDetails().getAdditionalProperties().get("expoPushToken");
178
179                    UserIdentity user = tokenToUserMap.get(pushToken);
180
181                    getLogger().debug("remove token {} for user {} messages: {}", pushToken, user, errorTicketMessagesString);
182
183                    _userPreferencesHelper.removeNotificationToken(pushToken, user);
184                }
185            }
186        }
187    }
188
189    /**
190     * Handle an exception essentially when trying to push notifications to multiple expo projects, to do it again for each project
191     * @param exception the exception thrown
192     * @param title title of the notification
193     * @param message description of the notification
194     * @param data data of the notification
195     * @param tokenToUserMap Reversed user/token map
196     * @throws PushClientException Something went very wrong
197     */
198    protected void _handlePushError(PushNotificationException exception, String title, String message, Map<String, Object> data, Map<String, UserIdentity> tokenToUserMap) throws PushClientException
199    {
200        Map<String, Set<String>> projects = new HashMap<>();
201        Exception cause = exception.exception;
202        if (cause instanceof PushNotificationErrorsException)
203        {
204            PushNotificationErrorsException error = (PushNotificationErrorsException) cause;
205            for (ExpoPushError expoPushError : error.errors)
206            {
207                Map<String, Object> additionalProperties = expoPushError.getAdditionalProperties();
208                if (additionalProperties != null)
209                {
210                    Object details = additionalProperties.get("details");
211                    if (details != null && details instanceof Map)
212                    {
213                        @SuppressWarnings("unchecked")
214                        Map<String, List<String>> detailsMap = (Map<String, List<String>>) details;
215                        for (Entry<String, List<String>> entry : detailsMap.entrySet())
216                        {
217                            if (!projects.containsKey(entry.getKey()))
218                            {
219                                projects.put(entry.getKey(), new HashSet<>());
220                            }
221                            projects.get(entry.getKey()).addAll(entry.getValue());
222                        }
223                    }
224                }
225            }
226            
227        }
228        
229        for (Entry<String, Set<String>> entry : projects.entrySet())
230        {
231            getLogger().warn("Trying to send push notification to multiple expo projects, one of them is {}, containing {} tokens.", entry.getKey(), entry.getValue().size());
232            Map<UserIdentity, Set<String>> tokensForOneProject = new HashMap<>();
233            for (String token : entry.getValue())
234            {
235                UserIdentity user = tokenToUserMap.get(token);
236                tokensForOneProject.computeIfAbsent(user, u -> new HashSet<>()).add(token);
237            }
238            pushNotifications(title, message, tokensForOneProject, data);
239        }
240    }
241}