Clover icon

Coverage Report

  1. Project Clover database Wed Nov 12 2025 09:00:47 GMT
  2. Package jalview.analytics

File Plausible.java

 

Coverage histogram

../../img/srcFileCovDistChart0.png
60% of files have more coverage

Code metrics

72
215
31
1
621
504
87
0.4
6.94
31
2.81

Classes

Class Line # Actions
Plausible 49 215 87
0.00%
 

Contributing tests

No tests hitting this source file were found.

Source view

1    /*
2    * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3    * Copyright (C) $$Year-Rel$$ The Jalview Authors
4    *
5    * This file is part of Jalview.
6    *
7    * Jalview is free software: you can redistribute it and/or
8    * modify it under the terms of the GNU General Public License
9    * as published by the Free Software Foundation, either version 3
10    * of the License, or (at your option) any later version.
11    *
12    * Jalview is distributed in the hope that it will be useful, but
13    * WITHOUT ANY WARRANTY; without even the implied warranty
14    * of MERCHANTABILITY or FITNESS FOR A PARTICULAR
15    * PURPOSE. See the GNU General Public License for more details.
16    *
17    * You should have received a copy of the GNU General Public License
18    * along with Jalview. If not, see <http://www.gnu.org/licenses/>.
19    * The Jalview Authors are detailed in the 'AUTHORS' file.
20    */
21    package jalview.analytics;
22   
23    import java.io.BufferedReader;
24    import java.io.IOException;
25    import java.io.InputStreamReader;
26    import java.io.OutputStream;
27    import java.io.UnsupportedEncodingException;
28    import java.lang.invoke.MethodHandles;
29    import java.net.HttpURLConnection;
30    import java.net.MalformedURLException;
31    import java.net.URL;
32    import java.net.URLEncoder;
33    import java.nio.charset.StandardCharsets;
34    import java.util.AbstractMap;
35    import java.util.ArrayList;
36    import java.util.Collections;
37    import java.util.HashMap;
38    import java.util.Iterator;
39    import java.util.List;
40    import java.util.Map;
41    import java.util.Random;
42   
43    import jalview.bin.Cache;
44    import jalview.bin.Console;
45    import jalview.util.ChannelProperties;
46    import jalview.util.HttpUtils;
47    import jalview.util.UserAgent;
48   
 
49    public class Plausible
50    {
51    private static final String USER_AGENT;
52   
53    private static final String JALVIEW_ID = "Jalview Desktop";
54   
55    private static final String DOMAIN = "jalview.org";
56   
57    private static final String CONFIG_API_BASE_URL = "https://www.jalview.org/services/config/analytics/url";
58   
59    private static final String DEFAULT_API_BASE_URL = "https://analytics.jalview.org/api/event";
60   
61    private static final String API_BASE_URL;
62   
63    private static final String clientId;
64   
65    public static final String APPLICATION_BASE_URL = "desktop://localhost";
66   
67    private List<Map.Entry<String, String>> queryStringValues;
68   
69    private List<Map.Entry<String, Object>> jsonObject;
70   
71    private List<Map.Entry<String, String>> cookieValues;
72   
73    private static boolean ENABLED = false;
74   
75    private static boolean DEBUG = true;
76   
77    private static Plausible instance = null;
78   
79    private static final Map<String, String> defaultProps;
80   
 
81  0 toggle static
82    {
83  0 defaultProps = new HashMap<>();
84  0 defaultProps.put("app_name",
85    ChannelProperties.getProperty("app_name") + " Desktop");
86  0 defaultProps.put("version", Cache.getProperty("VERSION"));
87  0 defaultProps.put("build_date",
88    Cache.getDefault("BUILD_DATE", "unknown"));
89  0 defaultProps.put("java_version", System.getProperty("java.version"));
90  0 String val = System.getProperty("sys.install4jVersion");
91  0 if (val != null)
92    {
93  0 defaultProps.put("install4j_version", val);
94    }
95  0 val = System.getProperty("installer.template_version");
96  0 if (val != null)
97    {
98  0 defaultProps.put("install4j_template_version", val);
99    }
100  0 val = System.getProperty("launcher.version");
101  0 if (val != null)
102    {
103  0 defaultProps.put("launcher.version", val);
104    }
105  0 defaultProps.put("java_arch",
106    System.getProperty("os.arch") + " "
107    + System.getProperty("os.name") + " "
108    + System.getProperty("os.version"));
109  0 defaultProps.put("os", System.getProperty("os.name"));
110  0 defaultProps.put("os_version", System.getProperty("os.version"));
111  0 defaultProps.put("os_arch", System.getProperty("os.arch"));
112  0 String installation = Cache.applicationProperties
113    .getProperty("INSTALLATION");
114  0 if (installation != null)
115    {
116  0 defaultProps.put("installation", installation);
117    }
118   
119    // ascertain the API_BASE_URL
120  0 API_BASE_URL = getAPIBaseURL();
121   
122    // random clientId to make User-Agent unique (to register analytic)
123  0 clientId = String.format("%08x", new Random().nextInt());
124   
125  0 USER_AGENT = UserAgent.getUserAgent(
126    MethodHandles.lookup().lookupClass().getCanonicalName() + " "
127    + clientId);
128    }
129   
 
130  0 toggle private Plausible()
131    {
132  0 this.resetLists();
133    }
134   
 
135  0 toggle public static void setEnabled(boolean b)
136    {
137  0 ENABLED = b;
138    }
139   
 
140  0 toggle public void sendEvent(String eventName, String urlString,
141    String... propsStrings)
142    {
143  0 sendEvent(eventName, urlString, false, propsStrings);
144    }
145   
 
146  0 toggle public void sendEvent(String eventName, String urlString,
147    boolean sendDefaultProps, String... propsStrings)
148    {
149  0 sendEvent(eventName, urlString, sendDefaultProps, propsStrings);
150    }
151   
152    /**
153    * The simplest way to send an analytic event.
154    *
155    * @param eventName
156    * The event name. To emulate a webpage view use "pageview" and set a
157    * "url" key/value. See https://plausible.io/docs/events-api
158    * @param sendDefaultProps
159    * Flag whether to add the default props about the application.
160    * @param appendQueryString
161    * Flag whether to append the queryString for log recording.
162    * @param propsStrings
163    * Optional multiple Strings in key, value pairs (there should be an
164    * even number of propsStrings) to be set as property of the event.
165    * To emulate a webpage view set "url" as the URL in a "pageview"
166    * event.
167    */
 
168  0 toggle public void sendEvent(String eventName, String urlString,
169    boolean sendDefaultProps, boolean appendQueryString,
170    String... propsStrings)
171    {
172    // clear out old lists
173  0 this.resetLists();
174   
175  0 if (!ENABLED)
176    {
177  0 Console.debug("Plausible not enabled.");
178  0 return;
179    }
180  0 Map<String, String> props = new HashMap<>();
181   
182    // add these to all events from this application instance
183  0 if (sendDefaultProps)
184    {
185  0 props.putAll(defaultProps);
186    }
187   
188    // add (and overwrite with) the passed in props
189  0 if (propsStrings != null && propsStrings.length > 0)
190    {
191  0 if (propsStrings.length % 2 != 0)
192    {
193  0 Console.warn(
194    "Cannot addEvent with odd number of propsStrings. Ignoring the last one.");
195    }
196  0 for (int i = 0; i < propsStrings.length - 1; i += 2)
197    {
198  0 String key = propsStrings[i];
199  0 String value = propsStrings[i + 1];
200  0 props.put(key, value);
201    }
202    }
203   
204  0 addJsonValue("domain", DOMAIN, appendQueryString);
205  0 addJsonValue("name", eventName, appendQueryString);
206  0 StringBuilder eventUrlSb = new StringBuilder(APPLICATION_BASE_URL);
207  0 if (!APPLICATION_BASE_URL.endsWith("/") && !urlString.startsWith("/"))
208    {
209  0 eventUrlSb.append("/");
210    }
211  0 eventUrlSb.append(urlString);
212  0 addJsonValue("url", eventUrlSb.toString(), appendQueryString);
213  0 addJsonObject("props", props);
214  0 StringBuilder urlSb = new StringBuilder();
215  0 urlSb.append(API_BASE_URL);
216  0 String qs = buildQueryString();
217  0 if (qs != null && qs.length() > 0)
218    {
219  0 urlSb.append('?');
220  0 urlSb.append(qs);
221    }
222  0 try
223    {
224  0 URL url = new URL(urlSb.toString());
225  0 HttpURLConnection httpURLConnection = (HttpURLConnection) HttpUtils
226    .openConnection(url);
227  0 httpURLConnection.setRequestMethod("POST");
228  0 httpURLConnection.setDoOutput(true);
229   
230  0 String jsonString = buildJson();
231   
232  0 Console.debug(
233    "Plausible: HTTP Request is: '" + urlSb.toString() + "'");
234  0 if (DEBUG)
235    {
236  0 Console.debug("Plausible: User-Agent is: '" + USER_AGENT + "'");
237    }
238  0 Console.debug("Plausible: POSTed JSON is:\n" + jsonString);
239   
240  0 byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
241  0 int jsonLength = jsonBytes.length;
242   
243  0 httpURLConnection.setFixedLengthStreamingMode(jsonLength);
244  0 httpURLConnection.setRequestProperty("Content-Type",
245    "application/json");
246  0 httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
247  0 httpURLConnection.connect();
248  0 try (OutputStream os = httpURLConnection.getOutputStream())
249    {
250  0 os.write(jsonBytes);
251    }
252  0 int responseCode = httpURLConnection.getResponseCode();
253  0 String responseMessage = httpURLConnection.getResponseMessage();
254   
255  0 if (responseCode < 200 || responseCode > 299)
256    {
257  0 Console.warn("Plausible connection failed: '" + responseCode + " "
258    + responseMessage + "'");
259    }
260    else
261    {
262  0 Console.debug("Plausible connection succeeded: '" + responseCode
263    + " " + responseMessage + "'");
264    }
265   
266  0 if (DEBUG)
267    {
268  0 BufferedReader br = new BufferedReader(new InputStreamReader(
269    (httpURLConnection.getInputStream())));
270  0 StringBuilder sb = new StringBuilder();
271  0 String response;
272  0 while ((response = br.readLine()) != null)
273    {
274  0 sb.append(response);
275    }
276  0 String body = sb.toString();
277  0 Console.debug("Plausible response content:\n" + body);
278    }
279    } catch (MalformedURLException e)
280    {
281  0 Console.debug(
282    "Somehow the Plausible BASE_URL and queryString is malformed: '"
283    + urlSb.toString() + "'",
284    e);
285  0 return;
286    } catch (IOException e)
287    {
288  0 Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
289    + "' failed.", e);
290    } catch (ClassCastException e)
291    {
292  0 Console.debug(
293    "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
294    e);
295    }
296    }
297   
 
298  0 toggle private void addJsonObject(String key, Map<String, String> map)
299    {
300  0 List<Map.Entry<String, ? extends Object>> list = new ArrayList<>();
301  0 for (String k : map.keySet())
302    {
303  0 list.add(stringEntry(k, map.get(k)));
304    }
305  0 addJsonObject(key, list);
306   
307    }
308   
 
309  0 toggle private void addJsonObject(String key,
310    List<Map.Entry<String, ? extends Object>> object)
311    {
312  0 jsonObject.add(objectEntry(key, object));
313    }
314   
 
315  0 toggle private void addJsonValues(String key, List<Object> values)
316    {
317  0 jsonObject.add(objectEntry(key, values));
318    }
319   
 
320  0 toggle private void addJsonValue(String key, String value)
321    {
322  0 addJsonValue(key, value, false);
323    }
324   
 
325  0 toggle private void addJsonValue(String key, String value,
326    boolean addToQueryString)
327    {
328  0 jsonObject.add(objectEntry(key, value));
329  0 if (addToQueryString)
330    {
331  0 addQueryStringValue(key, value);
332    }
333    }
334   
 
335  0 toggle private void addJsonValue(String key, int value)
336    {
337  0 jsonObject.add(objectEntry(key, Integer.valueOf(value)));
338    }
339   
 
340  0 toggle private void addJsonValue(String key, boolean value)
341    {
342  0 jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
343    }
344   
 
345  0 toggle private void addQueryStringValue(String key, String value)
346    {
347  0 queryStringValues.add(stringEntry(key, value));
348    }
349   
 
350  0 toggle private void addCookieValue(String key, String value)
351    {
352  0 cookieValues.add(stringEntry(key, value));
353    }
354   
 
355  0 toggle private void resetLists()
356    {
357  0 jsonObject = new ArrayList<>();
358  0 queryStringValues = new ArrayList<>();
359  0 cookieValues = new ArrayList<>();
360    }
361   
 
362  0 toggle public static Plausible getInstance()
363    {
364  0 if (instance == null)
365    {
366  0 instance = new Plausible();
367    }
368  0 return instance;
369    }
370   
 
371  0 toggle public static void reset()
372    {
373  0 getInstance().resetLists();
374    }
375   
 
376  0 toggle private String buildQueryString()
377    {
378  0 StringBuilder sb = new StringBuilder();
379  0 for (Map.Entry<String, String> entry : queryStringValues)
380    {
381  0 if (sb.length() > 0)
382    {
383  0 sb.append('&');
384    }
385  0 try
386    {
387  0 sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
388    } catch (UnsupportedEncodingException e)
389    {
390  0 sb.append(entry.getKey());
391    }
392  0 sb.append('=');
393  0 try
394    {
395  0 sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
396    } catch (UnsupportedEncodingException e)
397    {
398  0 sb.append(entry.getValue());
399    }
400    }
401  0 return sb.toString();
402    }
403   
 
404  0 toggle private void buildCookieHeaders()
405    {
406    // TODO not needed yet
407    }
408   
 
409  0 toggle private String buildJson()
410    {
411  0 StringBuilder sb = new StringBuilder();
412  0 addJsonObject(sb, 0, jsonObject);
413  0 return sb.toString();
414    }
415   
 
416  0 toggle private void addJsonObject(StringBuilder sb, int indent,
417    List<Map.Entry<String, Object>> entries)
418    {
419  0 indent(sb, indent);
420  0 sb.append('{');
421  0 newline(sb);
422  0 Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
423  0 while (entriesI.hasNext())
424    {
425  0 Map.Entry<String, Object> entry = entriesI.next();
426  0 String key = entry.getKey();
427    // TODO sensibly escape " characters in key
428  0 Object value = entry.getValue();
429  0 indent(sb, indent + 1);
430  0 sb.append('"').append(quoteEscape(key)).append('"').append(':');
431  0 space(sb);
432  0 if (value != null && value instanceof List)
433    {
434  0 newline(sb);
435    }
436  0 addJsonValue(sb, indent + 2, value);
437  0 if (entriesI.hasNext())
438    {
439  0 sb.append(',');
440    }
441  0 newline(sb);
442    }
443  0 indent(sb, indent);
444  0 sb.append('}');
445    }
446   
 
447  0 toggle private void addJsonValue(StringBuilder sb, int indent, Object value)
448    {
449  0 if (value == null)
450    {
451  0 return;
452    }
453  0 try
454    {
455  0 if (value instanceof Map.Entry)
456    {
457  0 Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
458  0 List<Map.Entry<String, Object>> object = new ArrayList<>();
459  0 object.add(entry);
460  0 addJsonObject(sb, indent, object);
461    }
462  0 else if (value instanceof List)
463    {
464    // list of Map.Entries or list of values?
465  0 List<Object> valueList = (List<Object>) value;
466  0 if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry)
467    {
468    // entries
469    // indent(sb, indent);
470  0 List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
471  0 addJsonObject(sb, indent, entryList);
472    }
473    else
474    {
475    // values
476  0 indent(sb, indent);
477  0 sb.append('[');
478  0 newline(sb);
479  0 Iterator<Object> valueListI = valueList.iterator();
480  0 while (valueListI.hasNext())
481    {
482  0 Object v = valueListI.next();
483  0 addJsonValue(sb, indent + 1, v);
484  0 if (valueListI.hasNext())
485    {
486  0 sb.append(',');
487    }
488  0 newline(sb);
489    }
490  0 indent(sb, indent);
491  0 sb.append("]");
492    }
493    }
494  0 else if (value instanceof String)
495    {
496  0 sb.append('"').append(quoteEscape((String) value)).append('"');
497    }
498  0 else if (value instanceof Integer)
499    {
500  0 sb.append(((Integer) value).toString());
501    }
502  0 else if (value instanceof Boolean)
503    {
504  0 sb.append('"').append(((Boolean) value).toString()).append('"');
505    }
506    } catch (ClassCastException e)
507    {
508  0 Console.debug(
509    "Could not deal with type of json Object " + value.toString(),
510    e);
511    }
512    }
513   
 
514  0 toggle private static String quoteEscape(String s)
515    {
516  0 if (s == null)
517    {
518  0 return null;
519    }
520    // this escapes quotation marks (") that aren't already escaped (in the
521    // string) ready to go into a quoted JSON string value
522  0 return s.replaceAll("((?<!\\\\)(?:\\\\{2})*)\"", "$1\\\\\"");
523    }
524   
 
525  0 toggle private static void prettyWhitespace(StringBuilder sb, String whitespace,
526    int repeat)
527    {
528    // only add whitespace if we're in DEBUG mode
529  0 if (!Console.getLogger().isDebugEnabled())
530    {
531  0 return;
532    }
533  0 if (repeat >= 0 && whitespace != null)
534    {
535    // sb.append(whitespace.repeat(repeat));
536  0 sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
537   
538    }
539    else
540    {
541  0 sb.append(whitespace);
542    }
543    }
544   
 
545  0 toggle private static void indent(StringBuilder sb, int indent)
546    {
547  0 prettyWhitespace(sb, " ", indent);
548    }
549   
 
550  0 toggle private static void newline(StringBuilder sb)
551    {
552  0 prettyWhitespace(sb, "\n", -1);
553    }
554   
 
555  0 toggle private static void space(StringBuilder sb)
556    {
557  0 prettyWhitespace(sb, " ", -1);
558    }
559   
 
560  0 toggle protected static Map.Entry<String, Object> objectEntry(String s, Object o)
561    {
562  0 return new AbstractMap.SimpleEntry<String, Object>(s, o);
563    }
564   
 
565  0 toggle protected static Map.Entry<String, String> stringEntry(String s, String v)
566    {
567  0 return new AbstractMap.SimpleEntry<String, String>(s, v);
568    }
569   
 
570  0 toggle private static String getAPIBaseURL()
571    {
572  0 try
573    {
574  0 URL url = new URL(CONFIG_API_BASE_URL);
575  0 HttpURLConnection httpURLConnection = (HttpURLConnection) HttpUtils
576    .openConnection(url);
577  0 httpURLConnection.setRequestMethod("GET");
578  0 httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
579  0 httpURLConnection.setConnectTimeout(5000);
580  0 httpURLConnection.setReadTimeout(3000);
581  0 httpURLConnection.connect();
582  0 int responseCode = httpURLConnection.getResponseCode();
583  0 String responseMessage = httpURLConnection.getResponseMessage();
584   
585  0 if (responseCode < 200 || responseCode > 299)
586    {
587  0 Console.warn("Config URL connection to '" + CONFIG_API_BASE_URL
588    + "' failed: '" + responseCode + " " + responseMessage
589    + "'");
590    }
591   
592  0 BufferedReader br = new BufferedReader(
593    new InputStreamReader((httpURLConnection.getInputStream())));
594  0 StringBuilder sb = new StringBuilder();
595  0 String response;
596  0 while ((response = br.readLine()) != null)
597    {
598  0 sb.append(response);
599    }
600  0 if (sb.length() > 7 && sb.substring(0, 5).equals("https"))
601    {
602  0 return sb.toString();
603    }
604   
605    } catch (MalformedURLException e)
606    {
607  0 Console.debug("Somehow the config URL is malformed: '"
608    + CONFIG_API_BASE_URL + "'", e);
609    } catch (IOException e)
610    {
611  0 Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
612    + "' failed.", e);
613    } catch (ClassCastException e)
614    {
615  0 Console.debug(
616    "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
617    e);
618    }
619  0 return DEFAULT_API_BASE_URL;
620    }
621    }