001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Desktop;
007import java.awt.Dimension;
008import java.awt.GraphicsEnvironment;
009import java.awt.event.KeyEvent;
010import java.io.BufferedReader;
011import java.io.BufferedWriter;
012import java.io.File;
013import java.io.FileInputStream;
014import java.io.IOException;
015import java.io.InputStreamReader;
016import java.io.OutputStream;
017import java.io.OutputStreamWriter;
018import java.io.Writer;
019import java.net.URI;
020import java.net.URISyntaxException;
021import java.nio.charset.StandardCharsets;
022import java.nio.file.FileSystems;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.nio.file.Paths;
026import java.security.KeyStore;
027import java.security.KeyStoreException;
028import java.security.NoSuchAlgorithmException;
029import java.security.cert.CertificateException;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.Collection;
033import java.util.List;
034import java.util.Locale;
035import java.util.Properties;
036
037import javax.swing.JOptionPane;
038
039import org.openstreetmap.josm.Main;
040import org.openstreetmap.josm.data.Preferences.pref;
041import org.openstreetmap.josm.data.Preferences.writeExplicitly;
042import org.openstreetmap.josm.gui.ExtendedDialog;
043import org.openstreetmap.josm.gui.util.GuiHelper;
044
045/**
046 * {@code PlatformHook} base implementation.
047 *
048 * Don't write (Main.platform instanceof PlatformHookUnixoid) because other platform
049 * hooks are subclasses of this class.
050 */
051public class PlatformHookUnixoid implements PlatformHook {
052
053    /**
054     * Simple data class to hold information about a font.
055     *
056     * Used for fontconfig.properties files.
057     */
058    public static class FontEntry {
059        /**
060         * The character subset. Basically a free identifier, but should be unique.
061         */
062        @pref
063        public String charset;
064
065        /**
066         * Platform font name.
067         */
068        @pref
069        @writeExplicitly
070        public String name = "";
071
072        /**
073         * File name.
074         */
075        @pref
076        @writeExplicitly
077        public String file = "";
078
079        /**
080         * Constructs a new {@code FontEntry}.
081         */
082        public FontEntry() {
083        }
084
085        /**
086         * Constructs a new {@code FontEntry}.
087         * @param charset The character subset. Basically a free identifier, but should be unique
088         * @param name Platform font name
089         * @param file File name
090         */
091        public FontEntry(String charset, String name, String file) {
092            this.charset = charset;
093            this.name = name;
094            this.file = file;
095        }
096    }
097
098    private String osDescription;
099
100    @Override
101    public void preStartupHook() {
102        // See #12022 - Disable GNOME ATK Java wrapper as it causes a lot of serious trouble
103        if ("org.GNOME.Accessibility.AtkWrapper".equals(System.getProperty("assistive_technologies"))) {
104            System.clearProperty("assistive_technologies");
105        }
106    }
107
108    @Override
109    public void afterPrefStartupHook() {
110        // Do nothing
111    }
112
113    @Override
114    public void startupHook() {
115        if (isDebianOrUbuntu()) {
116            // Invite users to install Java 8 if they are still with Java 7 and using a compatible distrib (Debian >= 8 or Ubuntu >= 15.10)
117            String java = System.getProperty("java.version");
118            String os = getOSDescription();
119            if (java != null && java.startsWith("1.7") && os != null && (
120                    os.startsWith("Linux Debian GNU/Linux 8") || os.matches("^Linux Ubuntu 1[567].*"))) {
121                String url;
122                // apturl does not exist on Debian (see #8465)
123                if (os.startsWith("Linux Debian")) {
124                    url = "https://packages.debian.org/jessie-backports/openjdk-8-jre";
125                } else if (getPackageDetails("apturl") != null) {
126                    url = "apt://openjdk-8-jre";
127                } else {
128                    url = "http://packages.ubuntu.com/xenial/openjdk-8-jre";
129                }
130                askUpdateJava(java, url);
131            }
132        }
133    }
134
135    @Override
136    public void openUrl(String url) throws IOException {
137        for (String program : Main.pref.getCollection("browser.unix",
138                Arrays.asList("xdg-open", "#DESKTOP#", "$BROWSER", "gnome-open", "kfmclient openURL", "firefox"))) {
139            try {
140                if ("#DESKTOP#".equals(program)) {
141                    Desktop.getDesktop().browse(new URI(url));
142                } else if (program.startsWith("$")) {
143                    program = System.getenv().get(program.substring(1));
144                    Runtime.getRuntime().exec(new String[]{program, url});
145                } else {
146                    Runtime.getRuntime().exec(new String[]{program, url});
147                }
148                return;
149            } catch (IOException | URISyntaxException e) {
150                Main.warn(e);
151            }
152        }
153    }
154
155    @Override
156    public void initSystemShortcuts() {
157        // CHECKSTYLE.OFF: LineLength
158        // TODO: Insert system shortcuts here. See Windows and especially OSX to see how to.
159        for (int i = KeyEvent.VK_F1; i <= KeyEvent.VK_F12; ++i) {
160            Shortcut.registerSystemShortcut("screen:toogle"+i, tr("reserved"), i, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
161                .setAutomatic();
162        }
163        Shortcut.registerSystemShortcut("system:reset", tr("reserved"), KeyEvent.VK_DELETE, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
164            .setAutomatic();
165        Shortcut.registerSystemShortcut("system:resetX", tr("reserved"), KeyEvent.VK_BACK_SPACE, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
166            .setAutomatic();
167        // CHECKSTYLE.ON: LineLength
168    }
169
170    /**
171     * This should work for all platforms. Yeah, should.
172     * See PlatformHook.java for a list of reasons why this is implemented here...
173     */
174    @Override
175    public String makeTooltip(String name, Shortcut sc) {
176        StringBuilder result = new StringBuilder();
177        result.append("<html>").append(name);
178        if (sc != null && !sc.getKeyText().isEmpty()) {
179            result.append(" <font size='-2'>(")
180                  .append(sc.getKeyText())
181                  .append(")</font>");
182        }
183        return result.append("&nbsp;</html>").toString();
184    }
185
186    @Override
187    public String getDefaultStyle() {
188        return "javax.swing.plaf.metal.MetalLookAndFeel";
189    }
190
191    @Override
192    public boolean canFullscreen() {
193        return !GraphicsEnvironment.isHeadless() &&
194                GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().isFullScreenSupported();
195    }
196
197    @Override
198    public boolean rename(File from, File to) {
199        return from.renameTo(to);
200    }
201
202    /**
203     * Determines if the distribution is Debian or Ubuntu, or a derivative.
204     * @return {@code true} if the distribution is Debian, Ubuntu or Mint, {@code false} otherwise
205     */
206    public static boolean isDebianOrUbuntu() {
207        try {
208            String dist = Utils.execOutput(Arrays.asList("lsb_release", "-i", "-s"));
209            return "Debian".equalsIgnoreCase(dist) || "Ubuntu".equalsIgnoreCase(dist) || "Mint".equalsIgnoreCase(dist);
210        } catch (IOException e) {
211            Main.warn(e);
212            return false;
213        }
214    }
215
216    /**
217     * Determines if the JVM is OpenJDK-based.
218     * @return {@code true} if {@code java.home} contains "openjdk", {@code false} otherwise
219     * @since 6951
220     */
221    public static boolean isOpenJDK() {
222        String javaHome = System.getProperty("java.home");
223        return javaHome != null && javaHome.contains("openjdk");
224    }
225
226    /**
227     * Get the package name including detailed version.
228     * @param packageNames The possible package names (when a package can have different names on different distributions)
229     * @return The package name and package version if it can be identified, null otherwise
230     * @since 7314
231     */
232    public static String getPackageDetails(String ... packageNames) {
233        try {
234            boolean dpkg = Files.exists(Paths.get("/usr/bin/dpkg-query"));
235            boolean eque = Files.exists(Paths.get("/usr/bin/equery"));
236            boolean rpm  = Files.exists(Paths.get("/bin/rpm"));
237            if (dpkg || rpm || eque) {
238                for (String packageName : packageNames) {
239                    String[] args;
240                    if (dpkg) {
241                        args = new String[] {"dpkg-query", "--show", "--showformat", "${Architecture}-${Version}", packageName};
242                    } else if (eque) {
243                        args = new String[] {"equery", "-q", "list", "-e", "--format=$fullversion", packageName};
244                    } else {
245                        args = new String[] {"rpm", "-q", "--qf", "%{arch}-%{version}", packageName};
246                    }
247                    String version = Utils.execOutput(Arrays.asList(args));
248                    if (version != null && !version.contains("not installed")) {
249                        return packageName + ':' + version;
250                    }
251                }
252            }
253        } catch (IOException e) {
254            Main.warn(e);
255        }
256        return null;
257    }
258
259    /**
260     * Get the Java package name including detailed version.
261     *
262     * Some Java bugs are specific to a certain security update, so in addition
263     * to the Java version, we also need the exact package version.
264     *
265     * @return The package name and package version if it can be identified, null otherwise
266     */
267    public String getJavaPackageDetails() {
268        String home = System.getProperty("java.home");
269        if (home.contains("java-7-openjdk") || home.contains("java-1.7.0-openjdk")) {
270            return getPackageDetails("openjdk-7-jre", "java-1_7_0-openjdk", "java-1.7.0-openjdk");
271        } else if (home.contains("icedtea")) {
272            return getPackageDetails("icedtea-bin");
273        } else if (home.contains("oracle")) {
274            return getPackageDetails("oracle-jdk-bin", "oracle-jre-bin");
275        }
276        return null;
277    }
278
279    /**
280     * Get the Web Start package name including detailed version.
281     *
282     * OpenJDK packages are shipped with icedtea-web package,
283     * but its version generally does not match main java package version.
284     *
285     * Simply return {@code null} if there's no separate package for Java WebStart.
286     *
287     * @return The package name and package version if it can be identified, null otherwise
288     */
289    public String getWebStartPackageDetails() {
290        if (isOpenJDK()) {
291            return getPackageDetails("icedtea-netx", "icedtea-web");
292        }
293        return null;
294    }
295
296    protected String buildOSDescription() {
297        String osName = System.getProperty("os.name");
298        if ("Linux".equalsIgnoreCase(osName)) {
299            try {
300                // Try lsb_release (only available on LSB-compliant Linux systems,
301                // see https://www.linuxbase.org/lsb-cert/productdir.php?by_prod )
302                Process p = Runtime.getRuntime().exec("lsb_release -ds");
303                try (BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
304                    String line = Utils.strip(input.readLine());
305                    if (line != null && !line.isEmpty()) {
306                        line = line.replaceAll("\"+", "");
307                        line = line.replaceAll("NAME=", ""); // strange code for some Gentoo's
308                        if (line.startsWith("Linux ")) // e.g. Linux Mint
309                            return line;
310                        else if (!line.isEmpty())
311                            return "Linux " + line;
312                    }
313                }
314            } catch (IOException e) {
315                // Non LSB-compliant Linux system. List of common fallback release files: http://linuxmafia.com/faq/Admin/release-files.html
316                for (LinuxReleaseInfo info : new LinuxReleaseInfo[]{
317                        new LinuxReleaseInfo("/etc/lsb-release", "DISTRIB_DESCRIPTION", "DISTRIB_ID", "DISTRIB_RELEASE"),
318                        new LinuxReleaseInfo("/etc/os-release", "PRETTY_NAME", "NAME", "VERSION"),
319                        new LinuxReleaseInfo("/etc/arch-release"),
320                        new LinuxReleaseInfo("/etc/debian_version", "Debian GNU/Linux "),
321                        new LinuxReleaseInfo("/etc/fedora-release"),
322                        new LinuxReleaseInfo("/etc/gentoo-release"),
323                        new LinuxReleaseInfo("/etc/redhat-release"),
324                        new LinuxReleaseInfo("/etc/SuSE-release")
325                }) {
326                    String description = info.extractDescription();
327                    if (description != null && !description.isEmpty()) {
328                        return "Linux " + description;
329                    }
330                }
331            }
332        }
333        return osName;
334    }
335
336    @Override
337    public String getOSDescription() {
338        if (osDescription == null) {
339            osDescription = buildOSDescription();
340        }
341        return osDescription;
342    }
343
344    protected static class LinuxReleaseInfo {
345        private final String path;
346        private final String descriptionField;
347        private final String idField;
348        private final String releaseField;
349        private final boolean plainText;
350        private final String prefix;
351
352        public LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField) {
353            this(path, descriptionField, idField, releaseField, false, null);
354        }
355
356        public LinuxReleaseInfo(String path) {
357            this(path, null, null, null, true, null);
358        }
359
360        public LinuxReleaseInfo(String path, String prefix) {
361            this(path, null, null, null, true, prefix);
362        }
363
364        private LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField, boolean plainText, String prefix) {
365            this.path = path;
366            this.descriptionField = descriptionField;
367            this.idField = idField;
368            this.releaseField = releaseField;
369            this.plainText = plainText;
370            this.prefix = prefix;
371        }
372
373        @Override public String toString() {
374            return "ReleaseInfo [path=" + path + ", descriptionField=" + descriptionField +
375                    ", idField=" + idField + ", releaseField=" + releaseField + ']';
376        }
377
378        /**
379         * Extracts OS detailed information from a Linux release file (/etc/xxx-release)
380         * @return The OS detailed information, or {@code null}
381         */
382        public String extractDescription() {
383            String result = null;
384            if (path != null) {
385                Path p = Paths.get(path);
386                if (Files.exists(p)) {
387                    try (BufferedReader reader = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
388                        String id = null;
389                        String release = null;
390                        String line;
391                        while (result == null && (line = reader.readLine()) != null) {
392                            if (line.contains("=")) {
393                                String[] tokens = line.split("=");
394                                if (tokens.length >= 2) {
395                                    // Description, if available, contains exactly what we need
396                                    if (descriptionField != null && descriptionField.equalsIgnoreCase(tokens[0])) {
397                                        result = Utils.strip(tokens[1]);
398                                    } else if (idField != null && idField.equalsIgnoreCase(tokens[0])) {
399                                        id = Utils.strip(tokens[1]);
400                                    } else if (releaseField != null && releaseField.equalsIgnoreCase(tokens[0])) {
401                                        release = Utils.strip(tokens[1]);
402                                    }
403                                }
404                            } else if (plainText && !line.isEmpty()) {
405                                // Files composed of a single line
406                                result = Utils.strip(line);
407                            }
408                        }
409                        // If no description has been found, try to rebuild it with "id" + "release" (i.e. "name" + "version")
410                        if (result == null && id != null && release != null) {
411                            result = id + ' ' + release;
412                        }
413                    } catch (IOException e) {
414                        // Ignore
415                        if (Main.isTraceEnabled()) {
416                            Main.trace(e.getMessage());
417                        }
418                    }
419                }
420            }
421            // Append prefix if any
422            if (result != null && !result.isEmpty() && prefix != null && !prefix.isEmpty()) {
423                result = prefix + result;
424            }
425            if (result != null)
426                result = result.replaceAll("\"+", "");
427            return result;
428        }
429    }
430
431    protected void askUpdateJava(String version) {
432        if (!GraphicsEnvironment.isHeadless()) {
433            askUpdateJava(version, "https://www.java.com/download");
434        }
435    }
436
437    protected void askUpdateJava(final String version, final String url) {
438        GuiHelper.runInEDTAndWait(new Runnable() {
439            @Override
440            public void run() {
441                ExtendedDialog ed = new ExtendedDialog(
442                        Main.parent,
443                        tr("Outdated Java version"),
444                        new String[]{tr("OK"), tr("Update Java"), tr("Cancel")});
445                // Check if the dialog has not already been permanently hidden by user
446                if (!ed.toggleEnable("askUpdateJava8").toggleCheckState()) {
447                    ed.setButtonIcons(new String[]{"ok", "java", "cancel"}).setCancelButton(3);
448                    ed.setMinimumSize(new Dimension(480, 300));
449                    ed.setIcon(JOptionPane.WARNING_MESSAGE);
450                    StringBuilder content = new StringBuilder(tr("You are running version {0} of Java.", "<b>"+version+"</b>"))
451                            .append("<br><br>");
452                    if ("Sun Microsystems Inc.".equals(System.getProperty("java.vendor")) && !isOpenJDK()) {
453                        content.append("<b>").append(tr("This version is no longer supported by {0} since {1} and is not recommended for use.",
454                                "Oracle", tr("April 2015"))).append("</b><br><br>");
455                    }
456                    content.append("<b>")
457                           .append(tr("JOSM will soon stop working with this version; we highly recommend you to update to Java {0}.", "8"))
458                           .append("</b><br><br>")
459                           .append(tr("Would you like to update now ?"));
460                    ed.setContent(content.toString());
461
462                    if (ed.showDialog().getValue() == 2) {
463                        try {
464                            openUrl(url);
465                        } catch (IOException e) {
466                            Main.warn(e);
467                        }
468                    }
469                }
470            }
471        });
472    }
473
474    @Override
475    public boolean setupHttpsCertificate(String entryAlias, KeyStore.TrustedCertificateEntry trustedCert)
476            throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException {
477        // TODO setup HTTPS certificate on Unix systems
478        return false;
479    }
480
481    @Override
482    public File getDefaultCacheDirectory() {
483        return new File(Main.pref.getUserDataDirectory(), "cache");
484    }
485
486    @Override
487    public File getDefaultPrefDirectory() {
488        return new File(System.getProperty("user.home"), ".josm");
489    }
490
491    @Override
492    public File getDefaultUserDataDirectory() {
493        // Use preferences directory by default
494        return Main.pref.getPreferencesDirectory();
495    }
496
497    /**
498     * <p>Add more fallback fonts to the Java runtime, in order to get
499     * support for more scripts.</p>
500     *
501     * <p>The font configuration in Java doesn't include some Indic scripts,
502     * even though MS Windows ships with fonts that cover these unicode ranges.</p>
503     *
504     * <p>To fix this, the fontconfig.properties template is copied to the JOSM
505     * cache folder. Then, the additional entries are added to the font
506     * configuration. Finally the system property "sun.awt.fontconfig" is set
507     * to the customized fontconfig.properties file.</p>
508     *
509     * <p>This is a crude hack, but better than no font display at all for these languages.
510     * There is no guarantee, that the template file
511     * ($JAVA_HOME/lib/fontconfig.properties.src) matches the default
512     * configuration (which is in a binary format).
513     * Furthermore, the system property "sun.awt.fontconfig" is undocumented and
514     * may no longer work in future versions of Java.</p>
515     *
516     * <p>Related Java bug: <a href="https://bugs.openjdk.java.net/browse/JDK-8008572">JDK-8008572</a></p>
517     *
518     * @param templateFileName file name of the fontconfig.properties template file
519     */
520    protected void extendFontconfig(String templateFileName) {
521        String customFontconfigFile = Main.pref.get("fontconfig.properties", null);
522        if (customFontconfigFile != null) {
523            Utils.updateSystemProperty("sun.awt.fontconfig", customFontconfigFile);
524            return;
525        }
526        if (!Main.pref.getBoolean("font.extended-unicode", true))
527            return;
528
529        String javaLibPath = System.getProperty("java.home") + File.separator + "lib";
530        Path templateFile = FileSystems.getDefault().getPath(javaLibPath, templateFileName);
531        if (!Files.isReadable(templateFile)) {
532            Main.warn("extended font config - unable to find font config template file "+templateFile.toString());
533            return;
534        }
535        try (FileInputStream fis = new FileInputStream(templateFile.toFile())) {
536            Properties props = new Properties();
537            props.load(fis);
538            byte[] content = Files.readAllBytes(templateFile);
539            File cachePath = Main.pref.getCacheDirectory();
540            Path fontconfigFile = cachePath.toPath().resolve("fontconfig.properties");
541            OutputStream os = Files.newOutputStream(fontconfigFile);
542            os.write(content);
543            try (Writer w = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) {
544                Collection<FontEntry> extrasPref = Main.pref.getListOfStructs(
545                        "font.extended-unicode.extra-items", getAdditionalFonts(), FontEntry.class);
546                Collection<FontEntry> extras = new ArrayList<>();
547                w.append("\n\n# Added by JOSM to extend unicode coverage of Java font support:\n\n");
548                List<String> allCharSubsets = new ArrayList<>();
549                for (FontEntry entry: extrasPref) {
550                    Collection<String> fontsAvail = getInstalledFonts();
551                    if (fontsAvail != null && fontsAvail.contains(entry.file.toUpperCase(Locale.ENGLISH))) {
552                        if (!allCharSubsets.contains(entry.charset)) {
553                            allCharSubsets.add(entry.charset);
554                            extras.add(entry);
555                        } else {
556                            Main.trace("extended font config - already registered font for charset ''{0}'' - skipping ''{1}''",
557                                    entry.charset, entry.name);
558                        }
559                    } else {
560                        Main.trace("extended font config - Font ''{0}'' not found on system - skipping", entry.name);
561                    }
562                }
563                for (FontEntry entry: extras) {
564                    allCharSubsets.add(entry.charset);
565                    if ("".equals(entry.name)) {
566                        continue;
567                    }
568                    String key = "allfonts." + entry.charset;
569                    String value = entry.name;
570                    String prevValue = props.getProperty(key);
571                    if (prevValue != null && !prevValue.equals(value)) {
572                        Main.warn("extended font config - overriding ''{0}={1}'' with ''{2}''", key, prevValue, value);
573                    }
574                    w.append(key + '=' + value + '\n');
575                }
576                w.append('\n');
577                for (FontEntry entry: extras) {
578                    if ("".equals(entry.name) || "".equals(entry.file)) {
579                        continue;
580                    }
581                    String key = "filename." + entry.name.replace(' ', '_');
582                    String value = entry.file;
583                    String prevValue = props.getProperty(key);
584                    if (prevValue != null && !prevValue.equals(value)) {
585                        Main.warn("extended font config - overriding ''{0}={1}'' with ''{2}''", key, prevValue, value);
586                    }
587                    w.append(key + '=' + value + '\n');
588                }
589                w.append('\n');
590                String fallback = props.getProperty("sequence.fallback");
591                if (fallback != null) {
592                    w.append("sequence.fallback=" + fallback + ',' + Utils.join(",", allCharSubsets) + '\n');
593                } else {
594                    w.append("sequence.fallback=" + Utils.join(",", allCharSubsets) + '\n');
595                }
596            }
597            Utils.updateSystemProperty("sun.awt.fontconfig", fontconfigFile.toString());
598        } catch (IOException ex) {
599            Main.error(ex);
600        }
601    }
602
603    /**
604     * Get a list of fonts that are installed on the system.
605     *
606     * Must be done without triggering the Java Font initialization.
607     * (See {@link #extendFontconfig(java.lang.String)}, have to set system
608     * property first, which is then read by sun.awt.FontConfiguration upon initialization.)
609     *
610     * @return list of file names
611     */
612    public Collection<String> getInstalledFonts() {
613        throw new UnsupportedOperationException();
614    }
615
616    /**
617     * Get default list of additional fonts to add to the configuration.
618     *
619     * Java will choose thee first font in the list that can render a certain character.
620     *
621     * @return list of FontEntry objects
622     */
623    public Collection<FontEntry> getAdditionalFonts() {
624        throw new UnsupportedOperationException();
625    }
626}