package esmska.data; import esmska.data.event.ValuedEventSupport; import esmska.data.event.ValuedListener; import esmska.utils.L10N; import esmska.data.Gateway.Feature; import java.beans.IntrospectionException; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.ResourceBundle; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import javax.script.Invocable; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; import javax.script.ScriptException; import javax.script.SimpleScriptContext; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.Validate; /** Class managing all gateways * @author ripper */ public class Gateways { /** Events fired when gateway collection is changed. * Please note that this events may be fired even from a non-EDT thread. */ public static enum Events { ADDED_GATEWAY, ADDED_GATEWAYS, REMOVED_GATEWAY, REMOVED_GATEWAYS, CLEARED_GATEWAYS, FAVORITES_UPDATED, HIDDEN_UPDATED, } /** shared instance */ private static final Config config = Config.getInstance(); private static final Gateways instance = new Gateways(); private static final Logger logger = Logger.getLogger(Gateways.class.getName()); private static final ResourceBundle l10n = L10N.l10nBundle; private static final SortedSet<Gateway> gateways = Collections.synchronizedSortedSet(new TreeSet<Gateway>()); private static final Set<DeprecatedGateway> deprecatedGateways = Collections.synchronizedSet(new HashSet<DeprecatedGateway>()); private static final Keyring keyring = Keyring.getInstance(); private static final ScriptEngineManager manager = new ScriptEngineManager(); private static final ScriptEngine jsEngine = manager.getEngineByName("js"); // <editor-fold defaultstate="collapsed" desc="ValuedEvent support"> private ValuedEventSupport<Events, Gateway> valuedSupport = new ValuedEventSupport<Events, Gateway>(this); public void addValuedListener(ValuedListener<Events, Gateway> valuedListener) { valuedSupport.addValuedListener(valuedListener); } public void removeValuedListener(ValuedListener<Events, Gateway> valuedListener) { valuedSupport.removeValuedListener(valuedListener); } // </editor-fold> /** Disabled constructor */ private Gateways() { config.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent evt) { // update favorite gateways on change if ("favoriteGateways".equals(evt.getPropertyName())) { updateFavorites(); } // update hidden gateways on change if ("hiddenGateways".equals(evt.getPropertyName())) { updateHidden(); } } }); } /** Get shared instance */ public static Gateways getInstance() { return instance; } /** Get collection of all gateways sorted by name */ public TreeSet<Gateway> getAll() { synchronized(gateways) { TreeSet<Gateway> gws = new TreeSet<Gateway>(gateways); return gws; } } /** Add new gateway * @param gateway new gateway, not null * @return See {@link Collection#add} */ public boolean add(Gateway gateway) { Validate.notNull(gateway); logger.log(Level.FINE, "Adding new gateway: {0}", gateway); boolean added = gateways.add(gateway); if (added) { updateFavorites(); updateHidden(); valuedSupport.fireEventOccured(Events.ADDED_GATEWAY, gateway); } return added; } /** Add new gateways * @param gateways collection of gateways, not null, no null element * @return See {@link Collection#addAll} */ public boolean addAll(Collection<Gateway> gateways) { Validate.notNull(gateways); Validate.noNullElements(gateways); logger.log(Level.FINE, "Adding {0} gateways: {1}", new Object[]{gateways.size(), gateways}); boolean changed = Gateways.gateways.addAll(gateways); if (changed) { updateFavorites(); updateHidden(); valuedSupport.fireEventOccured(Events.ADDED_GATEWAYS, null); } return changed; } /** Remove existing gateway * @param gateway gateway to be removed, not null * @return See {@link Collection#remove} */ public boolean remove(Gateway gateway) { Validate.notNull(gateway); logger.log(Level.FINE, "Removing gateway: {0}", gateway); boolean removed = gateways.remove(gateway); if (removed) { valuedSupport.fireEventOccured(Events.REMOVED_GATEWAY, gateway); } return removed; } /** Remove existing gateways * @param gateways collection of gateways to be removed, not null, no null element * @return See {@link Collection#removeAll} */ public boolean removeAll(Collection<Gateway> gateways) { Validate.notNull(gateways); Validate.noNullElements(gateways); logger.log(Level.FINE, "Removing {0} gateways: {1}", new Object[]{gateways.size(), gateways}); boolean changed = Gateways.gateways.removeAll(gateways); if (changed) { valuedSupport.fireEventOccured(Events.REMOVED_GATEWAYS, null); } return changed; } /** Remove all gateways */ public void clear() { logger.fine("Removing all gateways"); gateways.clear(); valuedSupport.fireEventOccured(Events.CLEARED_GATEWAYS, null); } /** Search for an existing gateway * @param gateway gateway to be searched, not null * @return See {@link Collection#contains} */ public boolean contains(Gateway gateway) { Validate.notNull(gateway); return gateways.contains(gateway); } /** Return number of gateways * @return See {@link Collection#size} */ public int size() { return gateways.size(); } /** Return if there are no gateways * @return See {@link Collection#isEmpty} */ public boolean isEmpty() { return gateways.isEmpty(); } /** Get set of currently deprecated gateways */ public HashSet<DeprecatedGateway> getDeprecatedGateways() { synchronized(deprecatedGateways) { HashSet<DeprecatedGateway> gws = new HashSet<DeprecatedGateway>(deprecatedGateways); return gws; } } /** Set currently deprecated gateways. May be null to clear them. */ public void setDeprecatedGateways(Set<DeprecatedGateway> deprecatedGateways) { synchronized(deprecatedGateways) { Gateways.deprecatedGateways.clear(); if (deprecatedGateways != null) { Gateways.deprecatedGateways.addAll(deprecatedGateways); } } } /** Find gateway by name. * @param gatewayName Name of the gateway. Search is case sensitive. May be null. * @return Gateway implementation, when a gateway with such name is found. * If multiple such gateways are found, returns the first one found. * Returns null if no gateway is found or provided name was null. */ public Gateway get(String gatewayName) { if (gatewayName == null) { return null; } synchronized(gateways) { for (Gateway gateway : gateways) { if (gateway.getName().equals(gatewayName)) { return gateway; } } } return null; } /** Returns whether the gateway is a fake one (used just for development * purposes). */ public static boolean isFakeGateway(String gatewayName) { return StringUtils.contains(gatewayName, "]fake"); } /** Guess gateway according to phone number or phone number prefix. * Searches through all visible (non-hidden) gateways and finds the best * suited ones (one or many) supporting this phone number. <br/><br/> * * Algorithm: * <ol> * <li>Any fake gateway is disqualified.</li> * <li>Any gateway that has some supported prefixes listed and yet is not matching * the number is disqualified.</li> * <li>If some gateways are marked as favorite: * <ol type=a> * <li>Discard gateways that have the number outside their preferred prefixes.</li> * <li>If there are some remaining, return all of them, sorted by the number of contacts assigned. * Selection ends here.</li> * </ol> * </li> * <li>If user has some contacts defined, count their numbers for each gateway: * <ol type=a> * <li>Discard gateways that have the number outside their preferred prefixes.</li> * <li>Gateway with the highest number of contacts win.</li> * <li>If multiple gateways have the same (highest) score, return all of them.</li> * <li>Selection ends here.</li> * </ol> * </li> * <li>Do the last-resort algorithm: * <ol type=a> * <li>All gateways have 0 points by default.</li> * <li>If a gateway requires login, but no credentials are filled in, subtract 1 point.</li> * <li>If a gateway has preferred prefixes defined and they don't match the number, subtract 1 point.</li> * <li>Return all gateways with the highest score. Selection ends here.</li> * </ol> * </li> * </ol> * * @param number phone number or its prefix. The minimum length is two characters, * for shorter input (or null) the method does nothing. * @return tuple consisting of: 1. list of suggested gateways (may be empty); 2. boolean whether this suggestion * is recommended (the decision was based on favorite gateways or the number of gateways users) * or completely arbitrary (the last-resort algorithm was used). */ public Tuple<ArrayList<Gateway>, Boolean> suggestGateway(String number) { if (number == null || number.length() < 2) { return new Tuple<ArrayList<Gateway>, Boolean>(new ArrayList<Gateway>(), false); } SortedSet<Gateway> selectedGateways = getVisible(); ArrayList<Gateway> result = new ArrayList<Gateway>(); final HashMap<String,Integer> usage = computeGatewayUsage(); // select only those gateways that support this number and are not fake for (Iterator<Gateway> it = selectedGateways.iterator(); it.hasNext(); ) { Gateway gw = it.next(); if (isFakeGateway(gw.getName())) { it.remove(); } else if (!isNumberSupported(gw, number)) { it.remove(); } else { // keep it in } } //search through favorite gateways for (Gateway gw : selectedGateways) { if (!gw.isFavorite()) { continue; } if (!isNumberPreferred(gw, number)) { continue; } result.add(gw); } if (!result.isEmpty()) { //sort by number of contacts Collections.sort(result, new Comparator<Gateway>() { @Override public int compare(Gateway o1, Gateway o2) { Integer popularity1 = usage.get(o1.getName()); Integer popularity2 = usage.get(o2.getName()); return popularity1.compareTo(popularity2); } }); return new Tuple<ArrayList<Gateway>, Boolean>(result, true); } //search through just popularity int max = 1; //gateways must have at least one contact for (Gateway gw : selectedGateways) { if (!isNumberPreferred(gw, number)) { continue; } int popularity = usage.get(gw.getName()); if (popularity > max) { max = popularity; result.clear(); result.add(gw); } else if (popularity == max) { result.add(gw); } } if (!result.isEmpty()) { return new Tuple<ArrayList<Gateway>, Boolean>(result, true); } //use last-resort algorithm // map of gateway -> score HashMap<Gateway, Integer> scores = new HashMap<Gateway, Integer>(); for (Gateway gw : selectedGateways) { scores.put(gw, 0); if (gw.hasFeature(Feature.LOGIN_ONLY) && keyring.getKey(gw.getName()) == null) { scores.put(gw, scores.get(gw) - 1); } if (!isNumberPreferred(gw, number)) { scores.put(gw, scores.get(gw) - 1); } } max = Integer.MIN_VALUE; for (Gateway gw : selectedGateways) { int score = scores.get(gw); if (score > max) { max = score; result.clear(); result.add(gw); } else if (score == max) { result.add(gw); } } return new Tuple<ArrayList<Gateway>, Boolean>(result, false); } /** Compute how many contacts use which gateway and return it as map <gw name; # of contacts> */ private HashMap<String, Integer> computeGatewayUsage() { HashMap<String, Integer> result = new HashMap<String, Integer>(); for (Contact contact : Contacts.getInstance().getAll()) { String gw = contact.getGateway(); if (result.containsKey(gw)) { result.put(gw, result.get(gw) + 1); } else { result.put(gw, 1); } } synchronized(gateways) { for (Gateway gw : gateways) { if (!result.containsKey(gw.getName())) { result.put(gw.getName(), 0); } } } return result; } /** Returns whether gateway matches the number with its supported prefixes. * * @param gateway gateway * @param number phone number * @return true if at least one of supported prefixes matches the number or * if the gateway does not have any supported prefixes; false otherwise */ public static boolean isNumberSupported(Gateway gateway, String number) { String[] supportedPrefixes = gateway.getSupportedPrefixes(); if (supportedPrefixes.length == 0) { // no supported prefixes -> gateway sends anywhere return true; } boolean matched = false; for (String prefix : supportedPrefixes) { if (number.startsWith(prefix)) { matched = true; break; } } return matched; } /** Returns whether gateway matches the number with its preferred prefixes. * * @param gateway gateway * @param number phone number * @return true if at least one of preferred prefixes matches the number or * if the gateway does not have any preferred prefixes; false otherwise * (or when gateway or number is null, or if number is shorter then 2 characters) */ public static boolean isNumberPreferred(Gateway gateway, String number) { if (gateway == null || number == null || number.length() < 2) { return false; } String[] preferredPrefixes = gateway.getPreferredPrefixes(); if (preferredPrefixes.length == 0) { // no preferred prefixes -> gateway sends anywhere in supported prefixes return true; } boolean matched = false; for (String prefix : preferredPrefixes) { if (number.startsWith(prefix)) { matched = true; break; } } return matched; } /** Convert message delay to more human readable string delay. * @param delay number of seconds (or milliseconds) of the delay * @param inMilliseconds if true,then <code>delay</code> is specified in * milliseconds, otherwise in seconds * @return human readable string of the delay, eg: "3h 15m 47s" */ public static String convertDelayToHumanString(long delay, boolean inMilliseconds) { if (inMilliseconds) { delay = Math.round(delay / 1000.0); } long seconds = delay % 60; long minutes = (delay / 60) % 60; long hours = delay / 3600; StringBuilder builder = new StringBuilder(); builder.append(seconds); builder.append(l10n.getString("QueuePanel.second_shortcut")); if (minutes > 0) { builder.insert(0, l10n.getString("QueuePanel.minute_shortcut") + " "); builder.insert(0, minutes); } if (hours > 0) { builder.insert(0, l10n.getString("QueuePanel.hour_shortcut") + " "); builder.insert(0, hours); } return builder.toString(); } /** Parse GatewayInfo implementation from the provided URL. * @param script URL (file or jar) of gateway script * @return GatewayInfo implementation * @throws IOException when there is problem accessing the script file * @throws ScriptException when the script is not valid * @throws IntrospectionException when current JRE does not support JavaScript execution */ public static GatewayInfo parseInfo(URL script) throws IOException, ScriptException, IntrospectionException { logger.log(Level.FINER, "Parsing info of script: {0}", script.toExternalForm()); if (jsEngine == null) { throw new IntrospectionException("JavaScript execution not supported"); } Invocable invocable = (Invocable) jsEngine; Reader reader = null; try { reader = new InputStreamReader(script.openStream(), "UTF-8"); //the script must be evaluated before extracting the interface SimpleScriptContext context = new SimpleScriptContext(); jsEngine.setContext(context); jsEngine.eval(reader); GatewayInfo gatewayInfo = invocable.getInterface(GatewayInfo.class); return gatewayInfo; } finally { try { reader.close(); } catch (Exception ex) { logger.log(Level.WARNING, "Error closing script: " + script.toExternalForm(), ex); } } } /** Get gateways marked as favorites */ public TreeSet<Gateway> getFavorites() { TreeSet<Gateway> favorites = new TreeSet<Gateway>(); synchronized(gateways) { for (Gateway gw : gateways) { if (gw.isFavorite()) { favorites.add(gw); } } } return favorites; } /** Get gateways marked as hidden */ public TreeSet<Gateway> getHidden() { TreeSet<Gateway> hidden = new TreeSet<Gateway>(); synchronized(gateways) { for (Gateway gw : gateways) { if (gw.isHidden()) { hidden.add(gw); } } } return hidden; } /** Get just the visible (non-hidden) gateways */ public TreeSet<Gateway> getVisible() { TreeSet<Gateway> visible = new TreeSet<Gateway>(); synchronized(gateways) { for (Gateway gw : gateways) { if (!gw.isHidden()) { visible.add(gw); } } } return visible; } /** Reload favorite gateways (after some change) */ private void updateFavorites() { Set<Gateway> oldFavorites = new HashSet<Gateway>(getFavorites()); for (Gateway gw : oldFavorites) { gw.setFavorite(false); } HashSet<String> favorites = new HashSet<String>(Arrays.asList(config.getFavoriteGateways())); HashSet<String> nonexistent = new HashSet<String>(); for (String gw : favorites) { Gateway gateway = get(gw); if (gateway == null) { nonexistent.add(gw); } else { gateway.setFavorite(true); } } Set<Gateway> newFavorites = getFavorites(); if (!nonexistent.isEmpty()) { logger.log(Level.FINE, "Found non-existent favorite gateways, removing from favorites: {0}", nonexistent); favorites = new HashSet<String>(favorites); favorites.removeAll(nonexistent); config.setFavoriteGateways(favorites.toArray(new String[]{})); } if (!CollectionUtils.isEqualCollection(oldFavorites, newFavorites)) { valuedSupport.fireEventOccured(Events.FAVORITES_UPDATED, null); } } /** Reload hidden gateways (after some change) */ private void updateHidden() { Set<Gateway> oldHidden = new HashSet<Gateway>(getHidden()); for (Gateway gw : oldHidden) { gw.setHidden(false); } Set<String> hidden = new HashSet<String>(Arrays.asList(config.getHiddenGateways())); HashSet<String> nonexistent = new HashSet<String>(); for (String gw : hidden) { Gateway gateway = get(gw); if (gateway == null) { nonexistent.add(gw); } else { gateway.setHidden(true); } } Set<Gateway> newHidden = getHidden(); if (!nonexistent.isEmpty()) { logger.log(Level.FINE, "Found non-existent hidden gateways, removing from hiddens: {0}", nonexistent); hidden = new HashSet<String>(hidden); hidden.removeAll(nonexistent); config.setHiddenGateways(hidden.toArray(new String[]{})); } if (!CollectionUtils.isEqualCollection(oldHidden, newHidden)) { valuedSupport.fireEventOccured(Events.HIDDEN_UPDATED, null); } } }