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            if (Main.isDebugEnabled()) {
212                // lsb_release is not available on all Linux systems, so don't log at warning level
213                Main.debug(e.getMessage());
214            }
215            return false;
216        }
217    }
218
219    /**
220     * Determines if the JVM is OpenJDK-based.
221     * @return {@code true} if {@code java.home} contains "openjdk", {@code false} otherwise
222     * @since 6951
223     */
224    public static boolean isOpenJDK() {
225        String javaHome = System.getProperty("java.home");
226        return javaHome != null && javaHome.contains("openjdk");
227    }
228
229    /**
230     * Get the package name including detailed version.
231     * @param packageNames The possible package names (when a package can have different names on different distributions)
232     * @return The package name and package version if it can be identified, null otherwise
233     * @since 7314
234     */
235    public static String getPackageDetails(String ... packageNames) {
236        try {
237            // CHECKSTYLE.OFF: SingleSpaceSeparator
238            boolean dpkg = Files.exists(Paths.get("/usr/bin/dpkg-query"));
239            boolean eque = Files.exists(Paths.get("/usr/bin/equery"));
240            boolean rpm  = Files.exists(Paths.get("/bin/rpm"));
241            // CHECKSTYLE.ON: SingleSpaceSeparator
242            if (dpkg || rpm || eque) {
243                for (String packageName : packageNames) {
244                    String[] args;
245                    if (dpkg) {
246                        args = new String[] {"dpkg-query", "--show", "--showformat", "${Architecture}-${Version}", packageName};
247                    } else if (eque) {
248                        args = new String[] {"equery", "-q", "list", "-e", "--format=$fullversion", packageName};
249                    } else {
250                        args = new String[] {"rpm", "-q", "--qf", "%{arch}-%{version}", packageName};
251                    }
252                    String version = Utils.execOutput(Arrays.asList(args));
253                    if (version != null && !version.contains("not installed")) {
254                        return packageName + ':' + version;
255                    }
256                }
257            }
258        } catch (IOException e) {
259            Main.warn(e);
260        }
261        return null;
262    }
263
264    /**
265     * Get the Java package name including detailed version.
266     *
267     * Some Java bugs are specific to a certain security update, so in addition
268     * to the Java version, we also need the exact package version.
269     *
270     * @return The package name and package version if it can be identified, null otherwise
271     */
272    public String getJavaPackageDetails() {
273        String home = System.getProperty("java.home");
274        if (home.contains("java-7-openjdk") || home.contains("java-1.7.0-openjdk")) {
275            return getPackageDetails("openjdk-7-jre", "java-1_7_0-openjdk", "java-1.7.0-openjdk");
276        } else if (home.contains("icedtea")) {
277            return getPackageDetails("icedtea-bin");
278        } else if (home.contains("oracle")) {
279            return getPackageDetails("oracle-jdk-bin", "oracle-jre-bin");
280        }
281        return null;
282    }
283
284    /**
285     * Get the Web Start package name including detailed version.
286     *
287     * OpenJDK packages are shipped with icedtea-web package,
288     * but its version generally does not match main java package version.
289     *
290     * Simply return {@code null} if there's no separate package for Java WebStart.
291     *
292     * @return The package name and package version if it can be identified, null otherwise
293     */
294    public String getWebStartPackageDetails() {
295        if (isOpenJDK()) {
296            return getPackageDetails("icedtea-netx", "icedtea-web");
297        }
298        return null;
299    }
300
301    protected String buildOSDescription() {
302        String osName = System.getProperty("os.name");
303        if ("Linux".equalsIgnoreCase(osName)) {
304            try {
305                // Try lsb_release (only available on LSB-compliant Linux systems,
306                // see https://www.linuxbase.org/lsb-cert/productdir.php?by_prod )
307                Process p = Runtime.getRuntime().exec("lsb_release -ds");
308                try (BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
309                    String line = Utils.strip(input.readLine());
310                    if (line != null && !line.isEmpty()) {
311                        line = line.replaceAll("\"+", "");
312                        line = line.replaceAll("NAME=", ""); // strange code for some Gentoo's
313                        if (line.startsWith("Linux ")) // e.g. Linux Mint
314                            return line;
315                        else if (!line.isEmpty())
316                            return "Linux " + line;
317                    }
318                }
319            } catch (IOException e) {
320                // Non LSB-compliant Linux system. List of common fallback release files: http://linuxmafia.com/faq/Admin/release-files.html
321                for (LinuxReleaseInfo info : new LinuxReleaseInfo[]{
322                        new LinuxReleaseInfo("/etc/lsb-release", "DISTRIB_DESCRIPTION", "DISTRIB_ID", "DISTRIB_RELEASE"),
323                        new LinuxReleaseInfo("/etc/os-release", "PRETTY_NAME", "NAME", "VERSION"),
324                        new LinuxReleaseInfo("/etc/arch-release"),
325                        new LinuxReleaseInfo("/etc/debian_version", "Debian GNU/Linux "),
326                        new LinuxReleaseInfo("/etc/fedora-release"),
327                        new LinuxReleaseInfo("/etc/gentoo-release"),
328                        new LinuxReleaseInfo("/etc/redhat-release"),
329                        new LinuxReleaseInfo("/etc/SuSE-release")
330                }) {
331                    String description = info.extractDescription();
332                    if (description != null && !description.isEmpty()) {
333                        return "Linux " + description;
334                    }
335                }
336            }
337        }
338        return osName;
339    }
340
341    @Override
342    public String getOSDescription() {
343        if (osDescription == null) {
344            osDescription = buildOSDescription();
345        }
346        return osDescription;
347    }
348
349    protected static class LinuxReleaseInfo {
350        private final String path;
351        private final String descriptionField;
352        private final String idField;
353        private final String releaseField;
354        private final boolean plainText;
355        private final String prefix;
356
357        public LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField) {
358            this(path, descriptionField, idField, releaseField, false, null);
359        }
360
361        public LinuxReleaseInfo(String path) {
362            this(path, null, null, null, true, null);
363        }
364
365        public LinuxReleaseInfo(String path, String prefix) {
366            this(path, null, null, null, true, prefix);
367        }
368
369        private LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField, boolean plainText, String prefix) {
370            this.path = path;
371            this.descriptionField = descriptionField;
372            this.idField = idField;
373            this.releaseField = releaseField;
374            this.plainText = plainText;
375            this.prefix = prefix;
376        }
377
378        @Override public String toString() {
379            return "ReleaseInfo [path=" + path + ", descriptionField=" + descriptionField +
380                    ", idField=" + idField + ", releaseField=" + releaseField + ']';
381        }
382
383        /**
384         * Extracts OS detailed information from a Linux release file (/etc/xxx-release)
385         * @return The OS detailed information, or {@code null}
386         */
387        public String extractDescription() {
388            String result = null;
389            if (path != null) {
390                Path p = Paths.get(path);
391                if (Files.exists(p)) {
392                    try (BufferedReader reader = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
393                        String id = null;
394                        String release = null;
395                        String line;
396                        while (result == null && (line = reader.readLine()) != null) {
397                            if (line.contains("=")) {
398                                String[] tokens = line.split("=");
399                                if (tokens.length >= 2) {
400                                    // Description, if available, contains exactly what we need
401                                    if (descriptionField != null && descriptionField.equalsIgnoreCase(tokens[0])) {
402                                        result = Utils.strip(tokens[1]);
403                                    } else if (idField != null && idField.equalsIgnoreCase(tokens[0])) {
404                                        id = Utils.strip(tokens[1]);
405                                    } else if (releaseField != null && releaseField.equalsIgnoreCase(tokens[0])) {
406                                        release = Utils.strip(tokens[1]);
407                                    }
408                                }
409                            } else if (plainText && !line.isEmpty()) {
410                                // Files composed of a single line
411                                result = Utils.strip(line);
412                            }
413                        }
414                        // If no description has been found, try to rebuild it with "id" + "release" (i.e. "name" + "version")
415                        if (result == null && id != null && release != null) {
416                            result = id + ' ' + release;
417                        }
418                    } catch (IOException e) {
419                        // Ignore
420                        if (Main.isTraceEnabled()) {
421                            Main.trace(e.getMessage());
422                        }
423                    }
424                }
425            }
426            // Append prefix if any
427            if (result != null && !result.isEmpty() && prefix != null && !prefix.isEmpty()) {
428                result = prefix + result;
429            }
430            if (result != null)
431                result = result.replaceAll("\"+", "");
432            return result;
433        }
434    }
435
436    protected void askUpdateJava(String version) {
437        if (!GraphicsEnvironment.isHeadless()) {
438            askUpdateJava(version, "https://www.java.com/download");
439        }
440    }
441
442    protected void askUpdateJava(final String version, final String url) {
443        GuiHelper.runInEDTAndWait(new Runnable() {
444            @Override
445            public void run() {
446                ExtendedDialog ed = new ExtendedDialog(
447                        Main.parent,
448                        tr("Outdated Java version"),
449                        new String[]{tr("OK"), tr("Update Java"), tr("Cancel")});
450                // Check if the dialog has not already been permanently hidden by user
451                if (!ed.toggleEnable("askUpdateJava8").toggleCheckState()) {
452                    ed.setButtonIcons(new String[]{"ok", "java", "cancel"}).setCancelButton(3);
453                    ed.setMinimumSize(new Dimension(480, 300));
454                    ed.setIcon(JOptionPane.WARNING_MESSAGE);
455                    StringBuilder content = new StringBuilder(tr("You are running version {0} of Java.", "<b>"+version+"</b>"))
456                            .append("<br><br>");
457                    if ("Sun Microsystems Inc.".equals(System.getProperty("java.vendor")) && !isOpenJDK()) {
458                        content.append("<b>").append(tr("This version is no longer supported by {0} since {1} and is not recommended for use.",
459                                "Oracle", tr("April 2015"))).append("</b><br><br>");
460                    }
461                    content.append("<b>")
462                           .append(tr("JOSM will soon stop working with this version; we highly recommend you to update to Java {0}.", "8"))
463                           .append("</b><br><br>")
464                           .append(tr("Would you like to update now ?"));
465                    ed.setContent(content.toString());
466
467                    if (ed.showDialog().getValue() == 2) {
468                        try {
469                            openUrl(url);
470                        } catch (IOException e) {
471                            Main.warn(e);
472                        }
473                    }
474                }
475            }
476        });
477    }
478
479    @Override
480    public boolean setupHttpsCertificate(String entryAlias, KeyStore.TrustedCertificateEntry trustedCert)
481            throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException {
482        // TODO setup HTTPS certificate on Unix systems
483        return false;
484    }
485
486    @Override
487    public File getDefaultCacheDirectory() {
488        return new File(Main.pref.getUserDataDirectory(), "cache");
489    }
490
491    @Override
492    public File getDefaultPrefDirectory() {
493        return new File(System.getProperty("user.home"), ".josm");
494    }
495
496    @Override
497    public File getDefaultUserDataDirectory() {
498        // Use preferences directory by default
499        return Main.pref.getPreferencesDirectory();
500    }
501
502    /**
503     * <p>Add more fallback fonts to the Java runtime, in order to get
504     * support for more scripts.</p>
505     *
506     * <p>The font configuration in Java doesn't include some Indic scripts,
507     * even though MS Windows ships with fonts that cover these unicode ranges.</p>
508     *
509     * <p>To fix this, the fontconfig.properties template is copied to the JOSM
510     * cache folder. Then, the additional entries are added to the font
511     * configuration. Finally the system property "sun.awt.fontconfig" is set
512     * to the customized fontconfig.properties file.</p>
513     *
514     * <p>This is a crude hack, but better than no font display at all for these languages.
515     * There is no guarantee, that the template file
516     * ($JAVA_HOME/lib/fontconfig.properties.src) matches the default
517     * configuration (which is in a binary format).
518     * Furthermore, the system property "sun.awt.fontconfig" is undocumented and
519     * may no longer work in future versions of Java.</p>
520     *
521     * <p>Related Java bug: <a href="https://bugs.openjdk.java.net/browse/JDK-8008572">JDK-8008572</a></p>
522     *
523     * @param templateFileName file name of the fontconfig.properties template file
524     */
525    protected void extendFontconfig(String templateFileName) {
526        String customFontconfigFile = Main.pref.get("fontconfig.properties", null);
527        if (customFontconfigFile != null) {
528            Utils.updateSystemProperty("sun.awt.fontconfig", customFontconfigFile);
529            return;
530        }
531        if (!Main.pref.getBoolean("font.extended-unicode", true))
532            return;
533
534        String javaLibPath = System.getProperty("java.home") + File.separator + "lib";
535        Path templateFile = FileSystems.getDefault().getPath(javaLibPath, templateFileName);
536        if (!Files.isReadable(templateFile)) {
537            Main.warn("extended font config - unable to find font config template file "+templateFile.toString());
538            return;
539        }
540        try (FileInputStream fis = new FileInputStream(templateFile.toFile())) {
541            Properties props = new Properties();
542            props.load(fis);
543            byte[] content = Files.readAllBytes(templateFile);
544            File cachePath = Main.pref.getCacheDirectory();
545            Path fontconfigFile = cachePath.toPath().resolve("fontconfig.properties");
546            OutputStream os = Files.newOutputStream(fontconfigFile);
547            os.write(content);
548            try (Writer w = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) {
549                Collection<FontEntry> extrasPref = Main.pref.getListOfStructs(
550                        "font.extended-unicode.extra-items", getAdditionalFonts(), FontEntry.class);
551                Collection<FontEntry> extras = new ArrayList<>();
552                w.append("\n\n# Added by JOSM to extend unicode coverage of Java font support:\n\n");
553                List<String> allCharSubsets = new ArrayList<>();
554                for (FontEntry entry: extrasPref) {
555                    Collection<String> fontsAvail = getInstalledFonts();
556                    if (fontsAvail != null && fontsAvail.contains(entry.file.toUpperCase(Locale.ENGLISH))) {
557                        if (!allCharSubsets.contains(entry.charset)) {
558                            allCharSubsets.add(entry.charset);
559                            extras.add(entry);
560                        } else {
561                            Main.trace("extended font config - already registered font for charset ''{0}'' - skipping ''{1}''",
562                                    entry.charset, entry.name);
563                        }
564                    } else {
565                        Main.trace("extended font config - Font ''{0}'' not found on system - skipping", entry.name);
566                    }
567                }
568                for (FontEntry entry: extras) {
569                    allCharSubsets.add(entry.charset);
570                    if ("".equals(entry.name)) {
571                        continue;
572                    }
573                    String key = "allfonts." + entry.charset;
574                    String value = entry.name;
575                    String prevValue = props.getProperty(key);
576                    if (prevValue != null && !prevValue.equals(value)) {
577                        Main.warn("extended font config - overriding ''{0}={1}'' with ''{2}''", key, prevValue, value);
578                    }
579                    w.append(key + '=' + value + '\n');
580                }
581                w.append('\n');
582                for (FontEntry entry: extras) {
583                    if ("".equals(entry.name) || "".equals(entry.file)) {
584                        continue;
585                    }
586                    String key = "filename." + entry.name.replace(' ', '_');
587                    String value = entry.file;
588                    String prevValue = props.getProperty(key);
589                    if (prevValue != null && !prevValue.equals(value)) {
590                        Main.warn("extended font config - overriding ''{0}={1}'' with ''{2}''", key, prevValue, value);
591                    }
592                    w.append(key + '=' + value + '\n');
593                }
594                w.append('\n');
595                String fallback = props.getProperty("sequence.fallback");
596                if (fallback != null) {
597                    w.append("sequence.fallback=" + fallback + ',' + Utils.join(",", allCharSubsets) + '\n');
598                } else {
599                    w.append("sequence.fallback=" + Utils.join(",", allCharSubsets) + '\n');
600                }
601            }
602            Utils.updateSystemProperty("sun.awt.fontconfig", fontconfigFile.toString());
603        } catch (IOException ex) {
604            Main.error(ex);
605        }
606    }
607
608    /**
609     * Get a list of fonts that are installed on the system.
610     *
611     * Must be done without triggering the Java Font initialization.
612     * (See {@link #extendFontconfig(java.lang.String)}, have to set system
613     * property first, which is then read by sun.awt.FontConfiguration upon initialization.)
614     *
615     * @return list of file names
616     */
617    public Collection<String> getInstalledFonts() {
618        throw new UnsupportedOperationException();
619    }
620
621    /**
622     * Get default list of additional fonts to add to the configuration.
623     *
624     * Java will choose thee first font in the list that can render a certain character.
625     *
626     * @return list of FontEntry objects
627     */
628    public Collection<FontEntry> getAdditionalFonts() {
629        throw new UnsupportedOperationException();
630    }
631}