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