/* * This file is part of the Illarion project. * * Copyright © 2015 - Illarion e.V. * * Illarion is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Illarion is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. */ package illarion.common.bug; import illarion.common.config.Config; import illarion.common.util.AppIdent; import illarion.common.util.DirectoryManager; import illarion.common.util.DirectoryManager.Directory; import illarion.common.util.MessageSource; import org.jetbrains.annotations.Contract; import org.mantisbt.connect.IMCSession; import org.mantisbt.connect.MCException; import org.mantisbt.connect.axis.MCSession; import org.mantisbt.connect.model.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; import java.util.concurrent.CountDownLatch; /** * This class stores the crash reporter itself. It holds all settings done to * the reporter and handles sending the crash reports as well as showing the * required dialogs. * * @author Martin Karing <nitram@illarion.org> */ public final class CrashReporter { /** * This is the key used in the configuration to store and read the settings * for the reporting system. */ public static final String CFG_KEY = "errorReport"; //$NON-NLS-1$ /** * This constant is used as mode value in case the crash reporter is * supposed to send the crash reports every time. */ public static final int MODE_ALWAYS = 1; /** * This constant is used as mode value in case the crash reporter is * supposed to ask the user if the message shall be send to the server or * not. */ public static final int MODE_ASK = 0; /** * This constant is used as mode value in case the crash reporter is * supposed to discard the crash report. */ public static final int MODE_NEVER = 2; /** * This URL is the URL of the server that is supposed to receive the crash * data using a HTTP POST request. */ @Nullable private static final URL CRASH_SERVER; /** * The singleton instance of this class. */ @Nonnull private static final CrashReporter INSTANCE = new CrashReporter(); /** * The logger instance that takes care for the logging output of this class. */ @Nonnull private static final Logger log = LoggerFactory.getLogger(CrashReporter.class); static { URL result = null; try { result = new URL("http://illarion.org/mantis/api/soap/mantisconnect.php"); //$NON-NLS-1$ } catch (@Nonnull MalformedURLException e) { log.warn("Preparing the crash report target URL failed. Crash reporter not functional."); //$NON-NLS-1$ } CRASH_SERVER = result; } /** * The configuration handler that is used for the settings of this class. */ @Nullable private Config cfg; /** * The currently displayed report dialog is displayed in this class. */ @Nullable private ReportDialog dialog; /** * This is the source of the messages that are displayed in the crash report * dialog. */ @Nullable private MessageSource messages; /** * This value stores the currently set mode. */ private int mode; /** * This is the factory that is used to create report dialogs. */ @Nullable private ReportDialogFactory dialogFactory; @Nullable private CountDownLatch crashReportDoneLatch; /** * Private constructor of the crash reporter that prepares all the required * data. */ private CrashReporter() { mode = MODE_ASK; } /** * Get the singleton instance of this class. * * @return the singleton instance of this class */ @Nonnull @Contract(pure = true) public static CrashReporter getInstance() { return INSTANCE; } /** * Set the instance of the factory that is used to create a report dialog. * * @param dialogFactory the dialog factory */ public void setDialogFactory(@Nullable ReportDialogFactory dialogFactory) { this.dialogFactory = dialogFactory; } /** * Report a crash to the Illarion Server in case the application is supposed * to do so. * * @param crash the data about the crash */ public void reportCrash(@Nonnull CrashData crash) { reportCrash(crash, false); } /** * Report a crash to the Illarion Server in case the application is supposed * to do so. * * @param crash the data about the crash * @param ownThread {@code true} in case the crash report is supposed * to be started in a additional thread */ public void reportCrash(@Nonnull CrashData crash, boolean ownThread) { if (ownThread) { new Thread(() -> { reportCrash(crash, false); }).start(); } if ("NoClassDefFoundError".equals(crash.getExceptionName())) { try { Files.createFile( DirectoryManager.getInstance().resolveFile(Directory.Data, "corrupted")); } catch (@Nonnull IOException e) { log.error("Failed to mark data as corrupted."); } } waitForReport(); switch (mode) { case MODE_ALWAYS: sendCrashData(crash); break; case MODE_NEVER: return; default: crashReportDoneLatch = new CountDownLatch(1); dialog = dialogFactory.createDialog(); dialog.setCrashData(crash); dialog.setMessageSource(messages); dialog.showDialog(); int result = dialog.getResult(); switch (result) { case ReportDialog.SEND_ALWAYS: setMode(MODE_ALWAYS); if (cfg != null) { cfg.set(CFG_KEY, MODE_ALWAYS); } sendCrashData(crash); break; case ReportDialog.SEND_ONCE: sendCrashData(crash); break; case ReportDialog.SEND_NEVER: setMode(MODE_NEVER); if (cfg != null) { cfg.set(CFG_KEY, MODE_NEVER); } break; default: break; } crashReportDoneLatch.countDown(); crashReportDoneLatch = null; dialog = null; break; } } /** * Set the configuration that is used for this crash reporter. * * @param config the new configuration */ public void setConfig(@Nullable Config config) { cfg = config; if (config != null) { setMode(config.getInteger(CFG_KEY)); } } /** * Set the message source that supplies the messages for the dialog. In case * this is set to {@code null} its impossible to display a window * asking the user if the error report shall be send or not. In this case no * report message will be send. * * @param source the new source of messages */ public void setMessageSource(MessageSource source) { messages = source; } /** * This function blocks the current thread from execution in case the crash * reporter is currently showing a crash report or is sending the * information on a crash to the server. */ public void waitForReport() { CountDownLatch localLatch = crashReportDoneLatch; if (localLatch != null) { try { localLatch.await(); } catch (InterruptedException e) { log.debug("Wait for report was interrupted!", e); // Thread interrupted. Just exit the function } } } private static final long REPRODUCIBILITY_NA_NUM = 100; private static final long SEVERITY_CRASH_NUM = 70; private static final long PRIORITY_HIGH_NUM = 40; private static final String CATEGORY = "Automatic"; /** * Send the data of the crash to the Illarion server. * * @param data the data that was collected about the crash */ private static void sendCrashData(@Nonnull CrashData data) { if (CRASH_SERVER == null) { return; } try { IMCSession mantisSession = new MCSession(CRASH_SERVER, "Java Reporting System", "dA23MvKT1KDm4k0bQmMS"); IProject[] projects = mantisSession.getAccessibleProjects(); IProject selectedProject = null; for (IProject project : projects) { if (project.getName().equalsIgnoreCase(data.getMantisProject())) { selectedProject = project; break; } } if (selectedProject == null) { log.error("Failed to find {} project.", data.getMantisProject()); return; } AppIdent application = data.getApplicationIdentifier(); String summery = data.getExceptionName() + " in Thread " + data.getThreadName(); String exceptionDescription = "Exception: " + data.getExceptionName() + "\nBacktrace:\n" + data.getStackBacktrace() + "\nDescription: " + data.getDescription(); String description = "Application:" + application.getApplicationIdentifier() + (application.getCommitCount() > 0 ? " (DEV)" : "") + "\nThread: " + data.getThreadName() + '\n' + exceptionDescription; @Nullable IIssue similarIssue = null; @Nullable IIssue possibleDuplicateIssue = null; @Nullable IIssue duplicateIssue = null; @Nonnull IIssueHeader[] headers = mantisSession.getProjectIssueHeaders(selectedProject.getId()); for (@Nonnull IIssueHeader header : headers) { if (!CATEGORY.equals(header.getCategory())) { continue; } if (!saveString(header.getSummary()).equals(summery)) { continue; } @Nonnull IIssue checkedIssue = mantisSession.getIssue(header.getId()); if (!saveString(checkedIssue.getDescription()).endsWith(exceptionDescription)) { continue; } similarIssue = checkedIssue; if (!saveString(checkedIssue.getVersion()).equals(application.getApplicationRootVersion())) { continue; } if (!saveString(checkedIssue.getOs()).equals(System.getProperty("os.name"))) { continue; } if (!saveString(checkedIssue.getOsBuild()).equals(System.getProperty("os.version"))) { continue; } possibleDuplicateIssue = checkedIssue; if (saveString(checkedIssue.getDescription()).equals(description)) { duplicateIssue = checkedIssue; break; } } if (duplicateIssue != null) { INote note = mantisSession.newNote("Same problem occurred again."); mantisSession.addNote(duplicateIssue.getId(), note); } else if (possibleDuplicateIssue != null) { INote note = mantisSession.newNote("A problem that is by all means very similar occurred:\n" + description + "\nOperating System: " + System.getProperty("os.name") + ' ' + System.getProperty("os.version")); mantisSession.addNote(possibleDuplicateIssue.getId(), note); } else { IIssue issue = mantisSession.newIssue(selectedProject.getId()); issue.setCategory(CATEGORY); issue.setSummary(summery); issue.setDescription(description); issue.setVersion(application.getApplicationRootVersion()); issue.setOs(System.getProperty("os.name")); issue.setOsBuild(System.getProperty("os.version")); issue.setReproducibility(new MCAttribute(REPRODUCIBILITY_NA_NUM, null)); issue.setSeverity(new MCAttribute(SEVERITY_CRASH_NUM, null)); issue.setPriority(new MCAttribute(PRIORITY_HIGH_NUM, null)); issue.setPrivate(false); long id = mantisSession.addIssue(issue); log.info("Added new Issue #{}", id); if (similarIssue != null) { mantisSession .addNote(id, mantisSession.newNote("Similar issue was found at #" + similarIssue.getId())); } } } catch (MCException e) { log.error("Failed to send error reporting data.", e); } } @Nonnull @Contract(pure = true) private static String saveString(@Nullable String input) { if (input == null) { return ""; } return input; } /** * Set a new value for the mode of this crash reporter. The legal values for * this mode are {@link #MODE_ALWAYS}, {@link #MODE_ASK} and * {@link #MODE_NEVER}. * * @param newMode the new mode value * @throws IllegalArgumentException in case the invalid mode value is chosen */ public void setMode(int newMode) { if ((newMode != MODE_ALWAYS) && (newMode != MODE_ASK) && (newMode != MODE_NEVER)) { mode = MODE_ASK; return; } mode = newMode; } }